Build a platform
Everything else in these docs assumes the accounts you post to are your own. If you are posting on behalf of your customers, profiles are how you keep them apart.
#The model
A profile is one of your end customers. You create one per customer, send them a connect URL so they attach their own accounts to it, and post with profileId. Your API key never leaves your server, your customer never signs in to yeetpost, and no credential of theirs passes through your code. Billing does not change, because yeetpost bills accounts rather than people: every connection counts on the same per-account ladder, you get one invoice, and you re-bill your customers however you like. An account can hold 1000 profiles at once, and a deleted one does not count toward that.
Profiles are routing; scoped keys are the boundary. A profile decides which connections a call reaches. A key scoped to a profile decides which objects the caller can see at all, which is what you want the moment a key lives anywhere but your own server. The five steps below are the whole flow.
externalId is your own id for the same customer, so their row in your database and their profile here agree without a second lookup table. It is optional, and unique across your live profiles: a second profile with the same one is refused with 409 profile_external_id_conflict.#1. Create a profile
One call per customer, at the moment you sign them up. Keep the id that comes back next to their row in your database.
curl -X POST "https://api.yeetpost.com/api/v2/profiles" \
-H "x-api-key: $YEETPOST_API_KEY" \
-H "content-type: application/json" \
-d '{
"name": "Acme Coffee",
"externalId": "cus_4821"
}'{
"profile": {
"id": "b2d1f0a3-5c6e-4f70-8a9b-0c1d2e3f4a5b",
"name": "Acme Coffee",
"externalId": "cus_4821",
"createdAt": "2026-08-29T09:24:11.412Z"
}
}GET /profiles lists yours newest first, paged with limit (1–100, default 25) and offset. PATCH /profiles/{profileId} takes a name, an externalId, or both.
#2. Send them a connect URL
POST /profiles/{profileId}/connect answers with a page yeetpost hosts. Send your customer there and they connect one of their own accounts into that profile.
curl -X POST "https://api.yeetpost.com/api/v2/profiles/b2d1f0a3-5c6e-4f70-8a9b-0c1d2e3f4a5b/connect" \
-H "x-api-key: $YEETPOST_API_KEY" \
-H "content-type: application/json" \
-d '{
"platform": "linkedin",
"redirectUrl": "https://acme.example.com/settings/social"
}'{
"url": "https://yeetpost.com/connect/8Qm2rXk9vT1sB4nE6yPzL0aJdHcWfUgR",
"expiresAt": "2026-08-29T09:39:11.412Z",
"connectSessionId": "7f3c9a10-2b4d-4e88-9f01-3a5b7c9d1e2f"
}platform is one of linkedin, x, bluesky, mastodon, discord, telegram or sandbox. Leave it out and the page shows a picker instead. It runs the platform's own consent screen where there is one and the credential form where there is not, so the same link handles either. headless: true takes the yeetpost header and footer off the page so it can sit in a popup with your own branding around it. Send them in a popup rather than an iframe: the platforms' consent screens refuse to be framed.
redirectUrl must be an https URL on a public host with no fragment; plain http is accepted for localhost and 127.0.0.1 so you can develop against it. It is bound to the session when you ask for the link and used exactly as stored, so nothing your customer's browser sends can change where they land. A test key opens a session for any profile and offers sandbox and nothing else, which is how you exercise the whole flow in CI without connecting anybody's real account.
#3. Store the mapping when they come back
The customer lands back on your url either way. The query says which connection arrived, or why none did:
# it worked
https://acme.example.com/settings/social
?connectSessionId=7f3c9a10-2b4d-4e88-9f01-3a5b7c9d1e2f&connectionId=3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b
&profileId=b2d1f0a3-5c6e-4f70-8a9b-0c1d2e3f4a5b&slug=acme_linkedin&platform=linkedin
# it did not
https://acme.example.com/settings/social?connectSessionId=7f3c9a10-2b4d-4e88-9f01-3a5b7c9d1e2f&error=cancellederror is cancelled when the customer backed out and connect_failed when the platform refused. Both mean the same thing to you: show the button again. If you would rather not read the query, poll the session, which says the same thing:
curl "https://api.yeetpost.com/api/v2/connect-sessions/7f3c9a10-2b4d-4e88-9f01-3a5b7c9d1e2f" \
-H "x-api-key: $YEETPOST_API_KEY"status is pending while the customer still has the link open, connected once an account came in, failed when they cancelled or the platform refused, and expired when the fifteen minutes ran out with nothing happening. A link that has been used or has run out shows a plain expiry page and sends nobody anywhere, which is why polling exists: there is no redirect left to carry the news.
Register one webhook for your whole account and you do not have to wait for a browser at all. connection.created and connection.removed tell you when a customer connects or disconnects an account, and post.sent, post.failed and post.deleted tell you what happened to their posts. Every one of them carries the profileId, so you can route the event to the right customer without a lookup.
curl -X POST "https://api.yeetpost.com/api/v2/webhooks" \
-H "x-api-key: $YEETPOST_API_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://example.com/hooks/yeetpost",
"events": ["connection.created", "connection.removed", "post.sent", "post.failed"]
}'{
"eventId": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"event": "connection.created",
"timestamp": "2026-08-29T09:24:12.004Z",
"connection": {
"id": "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b",
"profileId": "b2d1f0a3-5c6e-4f70-8a9b-0c1d2e3f4a5b",
"connectSessionId": "7f3c9a10-2b4d-4e88-9f01-3a5b7c9d1e2f",
"platformId": "linkedin",
"slug": "acme_linkedin"
}
}connectSessionId is the connect link the account came in through, so a receiver can file the connection against the customer it was minted for. The two ids to persist are the profile, which is your customer, and the connection, which is what you post to. Reconnecting an account that is already there replaces its credentials rather than making a new connection, so it fires nothing, and deleting a profile fires one connection.removed per account inside it. Signature verification, retries and the delivery log are on the webhooks page.
#4. Mint a key scoped to the profile
profileId on a call is a promise your own code makes. A key scoped to a profile is a promise the API keeps: it can only ever reach that one end customer, whatever the call says. Scope a key when something other than your own server holds it, when a customer of yours runs their own automation, or when you want one tenant's blast radius to end at that tenant. Mint one from your own onboarding code with POST /profiles/{profileId}/keys, below, or in the dashboard: pick the customer under Scope when you create the key. It still reads yp_secret_ or yp_test_, because scope is a property of the key rather than of its text, so nothing that already stores a key has to change shape.
What a key scoped to profile P can do:
- Post inside P.
profileIddefaults to P, a different one is a400, and slugs resolve inside P. The one-linerPOST /post/{connectionSlug}, which has nowhere to name a profile, reaches P's accounts instead of your own. - Read and manage P's things: its connections and their queues, its posts and drafts, its media, its webhooks.
GET /connectionsandGET /postslist P's and no one else's. - Read P itself with
GET /profiles/{profileId}and open a connect session for it, so a customer's own integration can add their next account. - Register a webhook that hears P's events only, rather than the whole account's.
What it cannot do:
- Manage profiles.
POST,GET /profiles,PATCHandDELETEare403 forbidden, and so is naming any profile but P. - Manage keys. All five key endpoints are
403 forbiddenfor a scoped key, its own listing included: a tenant boundary that can mint its own keys is not one. - Reach anything outside P by id. A post, connection, media object, webhook or connect session belonging to another profile is
404 not_found, and attaching another profile'smediaIdto a post isinvalid_media. - See the account through MCP.
list_connections,create_post,list_posts,get_postandcancel_scheduled_postobey the same rules as the endpoints they mirror.
404s are deliberate: an object outside the key's profile does not exist as far as the key is concerned, so an id cannot be probed for whether it is there. The 403s are deliberate too: managing profiles is a real capability the key does not have, and saying so saves you debugging a profile that is not missing.An Idempotency-Key is reserved inside the key's scope, so two of your customers sending order-123 on the same day get their own posts and their own replays. Scope and key type are independent: a test key scoped to a profile obeys both rules at once and reaches the sandbox connections inside that profile and nothing else, which is what you want in CI for a tenant integration. An unscoped key is unchanged in every respect, and is what you get when you pick no scope.
A key can also carry a list of origins its connect URLs may send a customer back to, one per line in the dashboard. With a list, POST /profiles/{profileId}/connect matches redirectUrl on scheme, host and port exactly: a path under an allowed origin is fine, a different host, a different port or plain http where you allowed https is 400 invalid_request. Leave the list empty and any https URL on a public host is accepted, which is the rule every key had before. The allowlist is what makes a leaked key useless for sending somebody's connection anywhere the holder likes, so it is worth setting on any key that leaves your own machines.
Onboarding a customer is code, so minting their key is too. Five endpoints do it: POST /profiles/{profileId}/keys mints one, GET /profiles/{profileId}/keys and GET /keys list keys by prefix, and DELETE /profiles/{profileId}/keys/{keyId} and DELETE /keys/{keyId} revoke.
curl -X POST "https://api.yeetpost.com/api/v2/profiles/b2d1f0a3-5c6e-4f70-8a9b-0c1d2e3f4a5b/keys" \
-H "x-api-key: $YEETPOST_API_KEY" \
-H "content-type: application/json" \
-d '{
"name": "Acme integration",
"type": "live",
"redirectAllowlist": [
"https://acme.example.com"
]
}'{
"key": {
"id": "9c4e1d75-6b3a-4f28-9d10-7e5c2a8b4f31",
"name": "Acme integration",
"type": "live",
"prefix": "yp_secret_4f9a2c",
"profileId": "b2d1f0a3-5c6e-4f70-8a9b-0c1d2e3f4a5b",
"redirectAllowlist": [
"https://acme.example.com"
],
"createdAt": "2026-08-30T09:24:11.412Z",
"lastUsedAt": null,
"revokedAt": null
},
"secret": "yp_secret_4f9a2c8e1b70d5a3c96f2e4b8d017a539e4a1d80b3f75c2e6a09d4b18f3c7e25"
}secret is answered once and no endpoint reads it back; the account owner can reveal a key in the console. Every later answer carries prefix instead, which tells two keys apart and cannot authenticate. A key minted here is always scoped to the profile in the path, so a leaked key of yours cannot be traded up for one that reaches every customer you have.
type is required rather than defaulted, because a key that can publish should not come from a field left out, and redirectAllowlist is worth setting here: a key handed to somebody else's integration is exactly the key that should not be able to send a customer anywhere. Only an unscoped key mints keys, a test key mints and manages test keys only, and a profile holds 50 live and 50 test keys at once before 403 limit_exceeded. Minting is paced at 20 an hour per account on top of the usual 60 a minute.
# offboarding one customer's integration
curl -X DELETE "https://api.yeetpost.com/api/v2/profiles/b2d1f0a3-5c6e-4f70-8a9b-0c1d2e3f4a5b/keys/9c4e1d75-6b3a-4f28-9d10-7e5c2a8b4f31" \
-H "x-api-key: $YEETPOST_API_KEY"200 twice with the first time kept. One thing outlives the key: a connect URL it already issued keeps working for the rest of its fifteen minutes, because that link carries its own single-use token. If a leaked key had opened connect links, wait those out and check GET /connect-sessions/{id} before you call the customer safe.#5. Post inside the profile
On an unscoped key, name the profile on the post and the request cannot leave it. On a scoped key you can leave profileId out and get the same guarantee. A slug belonging to another profile, or to you, fails that one item with invalid_connection while the rest of the request goes out.
curl -X POST "https://api.yeetpost.com/api/v2/posts" \
-H "x-api-key: $YEETPOST_API_KEY" \
-H "content-type: application/json" \
-d '{
"text": "our new roast is out",
"connectionSlugs": ["acme_linkedin"],
"profileId": "b2d1f0a3-5c6e-4f70-8a9b-0c1d2e3f4a5b"
}'Every connection and every post carries a profileId of its own, null when it is one of your own accounts, and profileId is part of the idempotency hash, so the same text to the same slug is a different request inside a profile from outside one. Read a customer's connections and history back the same way:
curl "https://api.yeetpost.com/api/v2/connections?profileId=b2d1f0a3-5c6e-4f70-8a9b-0c1d2e3f4a5b" \
-H "x-api-key: $YEETPOST_API_KEY"
curl "https://api.yeetpost.com/api/v2/posts?profileId=b2d1f0a3-5c6e-4f70-8a9b-0c1d2e3f4a5b" \
-H "x-api-key: $YEETPOST_API_KEY"Leave the filter off on an unscoped key and you get everything of yours, each row saying which profile it belongs to. A profileId that is not one of yours is 400 invalid_request rather than an empty list. The same account connected into two profiles is two connections, with their own slugs, history and queue, and it counts twice on your bill, because you are billing two of your own customers for it. Connecting it again inside the same profile is a reconnect and updates that connection in place.
Profiles are not scoped by key type. A test key manages the same profiles a live key does, and a sandbox connection can sit in a profile, which is how you test a customer's flow without publishing anything. The rest of the security checklist applies unchanged.
#Offboarding a customer
DELETE /profiles/{profileId} disconnects every account in the profile: the credentials are deleted, anything still waiting to be sent is cancelled, the drafts written in it are deleted, the keys and webhooks scoped to it are revoked, and your bill drops to the connections you have left. The answer says how many connections went with it.
curl -X DELETE "https://api.yeetpost.com/api/v2/profiles/b2d1f0a3-5c6e-4f70-8a9b-0c1d2e3f4a5b" \
-H "x-api-key: $YEETPOST_API_KEY"The profile itself is kept out of sight rather than erased. GET, PATCH and DELETE on it answer 404 not_found from then on and it drops out of GET /profiles, but its posts keep their profileId, so GET /posts?profileId=... still lists the history of a customer you have offboarded. Deleting a profile frees its external id, so a customer you take back later is the same externalId on a new profile.
#Compared with Zernio
Zernio's profiles are the same shape as ours, and the pieces they had and we did not, hosted connect URLs, headless mode, profile-scoped keys and the endpoints that mint and revoke them, are shipped here now. What is still theirs is breadth: sixteen platforms to our six, plus ads, and analytics off ten platforms we do not post to. One thing we do differently is offboarding: a deleted profile keeps its posts readable here, so a customer you drop does not take their history with them. The whole table is on yeetpost vs Zernio.