A REST API with Bearer auth, cursor pagination and HMAC-signed webhooks. Money amounts are always integer cents.
{ }
API & Webhooks
Build, automate and scale with Dropp.
https://api.external.dropp.fans/v1
Authentication
All API endpoints require a Bearer token:
HTTP
Authorization: Bearer drp_live_...
Pick the credential by what you are building:
You are…
Use
A server-to-server integration acting as one agency or creator (your own account)
An API key (drp_live_…) created in Settings → API
A registered partner app whose users paste their own dropp API keys
An App Key (X-Dropp-App-Key: drp_app_live_…) alongside each user's key
A product many creators connect to (CRM, chatting tool, analytics)
Login with dropp (OAuth) — one-click connect, scoped and revocable
Creating an API key
Go to Settings → API in your dashboard
Click Create Key
Choose a name, select scopes, and optionally restrict by IP
Copy the key — it's only shown once
Keys can be restricted to an IP allowlist: IPv4 addresses or CIDR ranges (for example 203.0.113.42 or 10.0.0.0/8). Requests from outside the allowlist receive 403 forbidden.
Test mode (drp_test_ keys)
Tick Test mode in the Create Key dialog (Settings → API → Create Key) to mint a drp_test_… key. It uses the same endpoints, scopes and base URL as a live key, but it lives in a separate partition of your account, Stripe-style:
Live key (drp_live_)
Test key (drp_test_)
Links and donation pages it creates
real sellables
sandbox sellables — the hosted checkout shows a TEST MODE — NO REAL CHARGES banner and runs on the payment provider's test environment
Money
real
none: no charge, no payout, no split, nothing on the creator's earnings or stats
test rows only — the two never mix, and every row carries livemode
Webhook endpoints it registers
receive live events
receive only test events, with livemode: false in every envelope
Creators (GET /v1/creators) and content
shared
shared
Wishlists, POST /v1/agency/creators
as documented
403 test_mode_unsupported — no test mode for these yet
Test cards on a sandbox checkout: Shift4 4242 4242 4242 4242 (any future expiry, any CVC); Nuvei 4000027891380961 (declined 4000020000000000, 3-D Secure challenge 4000023104662535). Which provider a checkout runs on depends on the creator's payment setup; both test cards are safe to try.
A typical dry run: create a test key with links:write, orders:read and webhooks:write → register a webhook endpoint with it → POST /v1/links (or /v1/donation-links) → open the returned checkout_url and pay with a test card → your endpoint receives order.paid with livemode: false, and GET /v1/orders with the test key lists the order. Repeat with your live key on a real purchase before going live.
Test keys have their own limit of 10 active keys per account, and a key's mode never changes: mint a live key when you go live. Test data is never deleted automatically, so name test links clearly.
Login with dropp (OAuth) — for tools serving multiple creators
If you build a product that many creators connect to, don't ask each of them to paste an API key. Register your app with dropp once (contact us — you receive a client_id, plus a client_secret for server-side apps), and creators connect with one click instead. The summary below is the short version; the full OAuth guide covers PKCE, token refresh and revocation in detail.
Step 1 — send the creator to the authorization URL (PKCE required, S256):
Step 2 — they log in, see your app's name and the exact scopes it was registered with, and approve. dropp redirects back with ?code=...&state=....
Step 3 — exchange the code server-side at POST https://auth.dropp.fans/auth/v1/oauth/token (grant_type=authorization_code, with your code_verifier). You receive an access_token (~1 h) and a rotating refresh_token — always store the newest one.
Step 4 — call the API with it. Same endpoints, same shapes:
Every call is scoped to the creator who connected: reads return their data, writes act on their behalf. Read effective_profile_id from GET /v1/me to know who that is.
Single-creator and multi-creator apps
Every registered app is either single-creator or multi-creator. You start single-creator; the flip is one-way and we only do it when you ask.
Single-creator. One creator connects and every call on their token is scoped to them. profile_id is optional, and accepted whenever it equals the connected creator.
Multi-creator. An agency owner delegates a whole roster in one consent, and one token acts for many creators. There is no default identity — a scoped call that names nobody fails with 400 ambiguous_scope.
Because an explicit profile_id is accepted in both modes, send it on every scoped call from day one and the flip is a no-op deploy. The query string works on every endpoint; the body only on endpoints that declare it.
Grant lifecycle webhooks
We can register an events webhook URL on your app (contact us; you receive its signing secret once). It gets a POST for app.grant.created, app.grant.updated and app.grant.revoked when a creator connects, changes, or disconnects your app. The envelope is { id, event, created_at, data }, with data carrying grant_id, profile_id, status (active / pending / revoked) and, on revoke, reason (approver_revoked, creator_revoked or membership_lost). Signature headers are the same as domain webhooks. Delivery is best-effort (3 quick attempts, no delivery log, no retry API) — treat it as a hint and reconcile against connected_creators from GET /v1/me.
Partner app identification (X-Dropp-App-Key)
If your users bring their own dropp API keys and you don't implement OAuth, we can register your app and issue an app credential instead. Send it on every request alongside the user's key:
HTTP
Authorization: Bearer drp_live_<the creator's own key>
X-Dropp-App-Key: drp_app_live_<your app credential>
The app key identifies your application, not a user — scopes and data access always come from the Authorization key. The app key unlocks partner-tier rate limits and per-app support diagnostics. It is a server-side secret: never ship it in a browser or public repo (we rotate it instantly if it leaks). An invalid app key fails the request — it is never silently ignored.
Base URL & Headers
Text
https://api.external.dropp.fans/v1
Everything is a REST API over HTTPS with JSON bodies. Money amounts are always integer cents (1990 is €19.90) — see the known issue on Orders — and timestamps are ISO-8601 in UTC.
Headers
Header
When
Notes
Authorization
Every request (except app-authenticated ones)
Bearer drp_live_… or an OAuth access_token. See Authentication
X-Dropp-App-Key
Registered partner apps
Identifies your app; required on provisioning and app-feed endpoints
Content-Type
Requests with a body
application/json
Idempotency-Key
Create endpoints
Any unique string. A retry with the same key replays the original response instead of creating a duplicate
Send an Idempotency-Key on every create call — links, copies, donation links, wishlists and creators. A network timeout then replays safely instead of racing. A conflicting use of the same key returns 409.
Acting for a creator (profile_id)
Agency keys and multi-creator OAuth apps act for more than one creator, so scoped calls name the creator with profile_id:
Query string works on every endpoint. When in doubt, use it.
Body is accepted only on the endpoints that declare it, such as POST /v1/links and POST /v1/content/upload-url.
Endpoints that don't declare it in the body — POST /v1/links/{id}/copies, POST /v1/links/{id}/vault-link — reject it there with 400. Use the query string.
Pass ?cursor=<next_cursor>&limit=25 to fetch the next page. The default limit is 25, the maximum 100. Stop when has_more is false.
Rate Limits
Scope
Limit
Window
Per IP
300 requests
60 seconds
Per key (burst)
10 requests
1 second
Per key (sustained)
100 requests
60 seconds
Rate limit status is returned in response headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. A 429 carries Retry-After in seconds.
Registered partner apps
Traffic from a registered app — OAuth tokens, or a user's key sent with your X-Dropp-App-Key — is checked at two levels:
Level
Meaning
Default
App (aggregate)
All traffic from your app, across every connected creator
1000 req/min · 50 req/s burst
Caller (per creator)
One creator's traffic through your app
100 req/min · 10 req/s burst
Registrations can carry higher limits — contact us when you need more; no client change is required. Requests sent without your app key still work, but fall back to the default per-key limits and aren't attributed to your app.
On 429, branch on error.code, never on the message:
rate_limited — one creator's budget. Back off for that creator only.
app_rate_limited — your app's shared ceiling. Apply one global throttle to the whole integration; per-creator backoff won't help.
Creator provisioning
POST /v1/agency/creators has its own budgets, counted per agency rather than per key: 30/hour and 200/day, plus a 300/hour aggregate for registered partner apps. Rejected addresses cost budget too — the windows are charged on attempt, not on success — and 409 email_unavailable carries a separate, much tighter budget (20/hour), so it is not a signal to iterate over addresses.
These budgets are fail-closed: if the limiter is unavailable, the endpoint refuses with 503 rather than creating accounts unmetered. Retry with backoff.
Video uploads
Video uploads have separate budgets because each video costs transcoding and storage: by default 100/hour and 500/week per creator, plus an app-wide ceiling of 1000/hour for partner apps. Image uploads have no upload budget.
Every video upload response reports the budget left after that upload:
app_per_hour appears only for partner-app traffic and is shared by every creator connected to your app, so per-creator budgets alone can't predict it. Run one global pacer for app_per_hour plus a pacer per creator for the other windows.
Login with dropp (OAuth)
Login with dropp lets creators connect their dropp account to your product with one click: they approve the scopes your app registered, you get short-lived tokens scoped to them, and they can disconnect you at any time. This guide walks through the full authorization code flow with PKCE, from registration to handling a revoked grant.
How Login with dropp works
Login with dropp is standard OAuth 2.1 with PKCE. The authorization server lives at https://auth.dropp.fans; the tokens it issues are used against the API at https://api.external.dropp.fans/v1.
Your server generates a PKCE verifier and a state value, then redirects the creator to the authorize URL.
The creator logs in to dropp, sees your app's name and scopes, and approves.
dropp redirects back to your redirect_uri with a one-time code.
Your server exchanges the code (plus the verifier) for an access token and a refresh token.
You call the API with Authorization: Bearer <access_token> and refresh when it expires.
The token only works against the dropp public API. It is not a dropp session: dashboard surfaces refuse it.
Registering your OAuth app
Registration is manual for now. Contact us with your app name, homepage, a contact email, the scopes you need and your redirect URIs. You receive:
Credential
Who gets it
Where it lives
client_id
Every app
Public. It appears in the authorize URL
client_secret
Server-side (confidential) apps only
Your backend only. It is shown once, so store it in your secret manager
Public clients (a native or single-page app with no backend) get no client_secret and rely on PKCE alone. Confidential clients send the secret in the token request body.
Redirect URIs must be https:// with no #fragment. The only exception is http://localhost, 127.0.0.1 or [::1] for local development. The redirect_uri you send must exactly match one you registered.
Step 1: Generating a PKCE verifier and challenge
PKCE is mandatory and only S256 is accepted. For every connection attempt, generate a fresh random code_verifier, derive the code_challenge from it, and keep the verifier and state server-side (for example in a short-lived, encrypted, httpOnly cookie) until the creator comes back.
Node.js
import { createHash, randomBytes } from "node:crypto";
const codeVerifier = randomBytes(32).toString("base64url"); // 43 chars
const codeChallenge = createHash("sha256")
.update(codeVerifier)
.digest("base64url"); // BASE64URL(SHA256(verifier)), no padding
const state = randomBytes(16).toString("base64url");
// Persist { codeVerifier, state, redirectUri } for ~10 minutes,
// bound to this browser session.
One of your registered URIs, URL-encoded. It must match exactly
code_challenge
BASE64URL(SHA256(code_verifier)) from step 1
code_challenge_method
Always S256
state
A random value you verify on the way back (CSRF protection)
There is no scope parameter: the consent screen always shows the scope set your app was registered with.
The creator logs in if needed, then approves. A creator who administers an agency also sees a picker to connect creators from their roster. See Preparing for the multi-creator flip.
Verify state against the value you stored. On a mismatch, or if you have no stored transaction, stop and don't exchange the code.
Handle a declined consent. If the creator declines, you get ?error=access_denied and no code. Show a "connection cancelled" state and don't retry automatically.
Use the code once, and promptly. It is single-use and short-lived. Clear your stored transaction as soon as you read it.
Step 4: Exchanging the code for tokens
Exchange the code from your server, never from the browser. The body is form-encoded:
redirect_uri must be the same value you sent to /authorize.
client_secret goes in the form body: confidential clients are registered for client_secret_post. Public clients omit it.
The JSON response carries an access_token and a refresh_token:
Token
Lifetime
Use
access_token
About 1 hour
A JWT. Send it as Authorization: Bearer <access_token> on every API call
refresh_token
Until it is used or the creator disconnects
Get a new token pair. It rotates: every refresh returns a new one
Right after the exchange, call GET /v1/me to learn which app and creator the token maps to, and store the creator's effective_profile_id next to the tokens.
Refreshing and rotating tokens
When the access token expires, the API answers 401. Get a new pair from the same token endpoint:
Refresh tokens rotate. Every successful refresh returns a new refresh_token: store it at once and treat the old one as dead. In practice:
Refresh on 401, then retry once. Or refresh ahead of time, a few minutes before the hour is up.
Serialise refreshes per connection. If two workers refresh with the same refresh token, one of them loses. Use a lock per creator connection, or route refreshes through a single place.
If the refresh is rejected, the grant is gone. The creator disconnected you. Clear the stored tokens and show a "Reconnect dropp" action instead of retrying.
Node.js
async function droppFetch(conn, path, init = {}) {
const call = (token) =>
fetch(`https://api.external.dropp.fans/v1${path}`, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${token}` },
});
let res = await call(conn.accessToken);
if (res.status !== 401) return res;
const tokens = await refresh(conn.refreshToken); // POST /oauth/token, grant_type=refresh_token
if (!tokens) {
await markDisconnected(conn); // clear tokens, prompt the creator to reconnect
throw new Error("dropp connection revoked");
}
await saveTokens(conn, tokens.access_token, tokens.refresh_token);
res = await call(tokens.access_token);
if (res.status === 401) await markDisconnected(conn);
return res;
}
Storing OAuth tokens safely
Keep client_secret, access tokens and refresh tokens server-side only: no browser bundles, mobile apps, logs or public repositories.
Encrypt refresh tokens at rest. Key them by your own user and the dropp creator (effective_profile_id), so one of your users can hold several dropp connections.
Store the token pair atomically. Writing a new access token next to an old refresh token breaks the next refresh.
If your client_secret leaks, contact us right away so we can rotate your client credentials.
Calling the API with an access token
The endpoints and response shapes are the same as with an API key:
Every call acts for exactly one creator. On a single-creator app, that is the creator who connected, so profile_id is optional. Send it anyway (see Preparing for the multi-creator flip). Put profile_id in the query string, which works on every endpoint, or in the body where an endpoint declares it. If you send both, they must be equal, or the call fails with 400 bad_request.
Your traffic is attributed to your app. Each connected creator has their own budget, and your app also has an aggregate ceiling. See Rate Limits.
Consent and scope snapshots
When a creator approves, their grant records your app's scope set at that moment. From then on, what a token can do is the intersection of that snapshot and your app's current registration:
Change
Effect on existing connections
We remove a scope from your app
Takes effect on the next request for every connected creator
We add a scope to your app
Existing grants don't get it. Calls needing it return 403 forbidden for those creators
Getting new scopes onto existing connections
Sending a creator through /authorize again does not refresh their snapshot, because repeat authorizations are auto-approved. To pick up a new scope, the creator has to disconnect your app in Settings → API → Connected Apps and connect again.
Use scopes_stale: true on a connected_creators row in GET /v1/me to find the creators who need it. Then show them a "Reconnect to enable …" prompt instead of failing silently.
Disconnects and revoked grants
Creators can disconnect your app at any time from Settings → API → Connected Apps. Disconnecting kills your tokens and refresh tokens immediately, not when the access token expires.
What happened
What you see
What to do
The creator disconnected your app
401 unauthorized, and the refresh is rejected too
Clear the tokens and offer "Reconnect dropp"
The install has no callable creator left
403 no_active_grant
Same as above: the creator has to connect again
One delegated creator was removed (multi-creator)
403 profile_not_connected for that profile_id only
Drop that creator from your roster. Keep serving the others
Your app was suspended
403 forbidden on every call
Contact us
Don't retry in a loop on any of these, because they aren't transient. With an events webhook registered, you also get app.grant.revoked (with a reason) close to real time. Delivery is best-effort, so reconcile against GET /v1/me as well.
Preparing for the multi-creator flip
Every app starts single-creator: the token acts for the creator who connected. On request, we flip an app to multi-creator, where an agency admin can connect a whole roster in one consent and a single token acts for many creators. The flip is one-way and changes identity resolution on every request:
Single-creator
Multi-creator
Default creator
The connected creator
None, ever
profile_id
Optional (must equal the connected creator)
Required on every scoped call, or 400 ambiguous_scope
effective_profile_id in /v1/me
The connected creator
null until a request names a creator
Creators delegated by an agency admin
Recorded as pending and not callable
active and callable
To make the flip a no-op deploy:
Send profile_id on every scoped call from day one. It is accepted in both modes.
Type effective_profile_id as nullable.
Build your creator list from connected_creators in GET /v1/me. Keep rows where status is active, and read granted_via_agency to tell a creator who connected themselves from one an agency delegated. When an agency delegated the creator, their money may be held by the agency. See Account.
Never send an agency id as profile_id. It returns 403 profile_not_connected.
On a single-creator app, an agency admin who connects only other creators produces an install with nothing callable. Every call returns 403 no_active_grant until the creator connects themselves or we flip your app. At the flip, pending delegated grants become active.
Two limits on the agency side: one install can connect at most 200 creators (grant_limit_reached). A creator who removes an agency-delegated connection also blocks the agency from re-adding them until the creator lifts the block.
OAuth errors to handle
API errors use the standard error envelope. Branch on error.code:
Status
error.code
Meaning
Action
401
unauthorized
Access token expired, invalid or revoked
Refresh once. If the refresh fails, reconnect
400
ambiguous_scope
Multi-creator app, and no profile_id sent
Send profile_id
400
bad_request
Different profile_id values in the query and the body
Send one value
403
profile_not_connected
That creator is not in your active grant set
Refresh your roster from GET /v1/me
403
no_active_grant
The install has no callable creator
The creator reconnects
403
forbidden
A scope is missing from this creator's grant, the grant was revoked, or your app is suspended
On the OAuth redirect and the token endpoint, handle error=access_denied (the creator declined) and a failed code exchange (the code expired or was reused, or the redirect_uri, code_verifier or client credentials are wrong). For a failed exchange, start a new authorization with a fresh verifier and state.
OAuth security checklist
PKCE S256 and a checked state on every authorization.
Code exchange and refresh on your server. The browser never sees client_secret or tokens.
Only exact, https:// redirect URIs registered with us.
Refresh tokens rotated and stored atomically, with one refresh in flight per connection.
401 handled as "refresh once, then reconnect", never as a retry loop.
Provision Creators (Referral)
Approved affiliate partners can create a dropp seller account through the API with their referral code already attached. You earn referral giveback on that seller's future sales, just as if they had signed up with your code on the site, without relying on ?ref= links.
When to provision a referred seller
Use POST /v1/creators when you bring a new seller to dropp and want the attribution to be certain from the start. For example, you might onboard creators in your own product and open their dropp account as part of that flow.
It is the wrong tool when:
You are an agency adding your own talent. Use POST /v1/agency/creators with your agency API key instead. The creator joins your agency on a revenue split. See Account.
The seller already has a dropp account. Provisioning never re-attributes an existing account, so the call changes nothing (see Provisioning response and statuses). Ask them to connect your app with Login with dropp instead.
Provisioning prerequisites
Both of these are set up by dropp when you are onboarded as an affiliate. You can't set them up yourself, because they decide where money is attributed:
Requirement
What it is
creators:provision on your app
An app-level scope. It is never part of a creator API key or an OAuth grant
A bound referral code
One of your referral codes, bound to your app. Every seller you provision is attributed to it, and it must be active
You also need your App Key (drp_app_live_…). Contact us if you don't have one yet.
Calling POST /v1/creators
Every other endpoint acts for a creator. Here there is no account yet, so your App Key authenticates the call by itself. Send X-Dropp-App-Key and no Authorization header:
Set type to AGENCY when the seller you're referring will manage other creators. This creates an agency with the invited person as its ADMIN. You are not a member of it.
The seller's dropp profile id. Store it: you use it as profile_id when acting for them
email
The address in lowercase
type
What the account is, which can differ from what you asked for on already_exists
agency_id
The new agency's id for a provisioned AGENCY. Otherwise null, and always null on already_exists
status
provisioned or already_exists (see below)
referral_code
The code the account is attributed to, or null if an existing account has none
referral_claimed
true when this call attached your code
status
What happened
provisioned
The account was created, your code attached, the invite emailed, and an install created for your app
already_exists
The email already had a dropp account. Nothing changed: no re-attribution, no invite, no install. referral_claimed is false and referral_code shows the existing claim, if any
A seller keeps the referral they first signed up under. already_exists doesn't tell you who that is, and you can't claim someone else's seller.
Retrying provisioning safely
Always send an Idempotency-Key (a UUID per new seller, up to 255 characters). Keys are scoped to your app:
Retry
Result
Same key, same body, after a success
The original response is replayed with an Idempotent-Replay: true header
Same key, while the first request is still running
409 conflict. Wait and retry
Same key, different body
409 conflict. Use a new key for a new request
Same key, after an error
The key is released, so the retry runs again
Without a key, a retry after a timeout still won't create a second account: the email already exists, so you get already_exists with referral_claimed: false. The account and the install from your first call remain valid, but that response hides them. That is why the key matters.
After provisioning: invite, KYC and ownership
The person takes the account over by email. dropp is passwordless: the invite link is how they log in for the first time. They then verify their email and finish onboarding (name, KYC, payout details).
They can't sell until KYC passes. Until a real person takes the account over, it is inert. An unclaimed invite costs nothing, and your giveback accrues only on real, paid sales.
They own the account, not you. For an AGENCY, the invited person is its ADMIN. They can revoke your app's access at any time (see below).
Acting as a provisioned creator
Provisioning also creates an install: your app can act as that creator with the same X-Dropp-App-Key and no Bearer token, as long as you name them with profile_id. The rules are in Account. A few points matter for planning:
Only accounts that were provisioned by your app. An already_exists response creates no install. A creator who connected through Login with dropp is reachable with their OAuth token, not with your App Key.
Scopes are fixed when you provision. The install carries your app's creator-facing scopes at that moment (never creators:provision), narrowed by any scope we later remove from your app. A scope added to your app later doesn't reach installs created earlier. Ask for the scopes you need before you start provisioning.
Revocation is final for your app. The install appears in the creator's Settings → API → Connected Apps. If they remove it, every later call returns 403 profile_not_connected. Treat that as "this creator is no longer mine", not as a retryable error. If you registered an events webhook, you also get app.grant.revoked. No app.grant.created is sent for installs, so the 201 response is your record.
Provisioning budgets
POST /v1/creators is rate-limited as your app's own traffic, not per agency:
Traffic
Budget (defaults)
POST /v1/creators calls
10 per second and 100 per minute for your app
Each provisioned creator you act for
10 per second and 100 per minute per creator
Everything your app sends
The app-wide ceiling of 50 per second and 1,000 per minute, shared with the rows above
A 429 carries Retry-After. Branch on rate_limited or app_rate_limited as described in Rate Limits. The 30/hour and 200/day budgets on that page belong to POST /v1/agency/creators and don't apply here. Need more for a bulk import? Contact us.
Provisioning errors
Status
error.code
When
What to do
400
bad_request
Invalid body: a bad email, agency_name missing or too short for AGENCY, a malformed referral_code, or an Idempotency-Key over 255 characters
Fix the request
401
unauthorized
Missing or invalid X-Dropp-App-Key
Check the header
403
forbidden
Your app lacks creators:provision, the referral_code isn't your bound code, or your app is suspended
Remove referral_code, or contact us
409
conflict
Idempotency-Key reused with a different body, or while the first request is still running
No referral code is bound to your app, or the bound code is inactive
Contact us. Retrying won't help
429
rate_limited / app_rate_limited
Budget exceeded
Wait Retry-After seconds
503
service_unavailable
Account creation or a dependency was briefly unavailable
Retry with backoff and the sameIdempotency-Key
409 email_unavailable, 502 provisioning_failed and 503 invites_unavailable in the error reference belong to POST /v1/agency/creators. This endpoint never returns them: an existing email is a 201 with already_exists.
Calls you make as a provisioned creator can also return 400 ambiguous_scope (no profile_id) and 403 profile_not_connected (not your install, or revoked).
Referral attribution FAQ
Can I attribute an existing account to my code? No. already_exists never re-attributes. This protects every referrer's earnings.
Do I become the seller's agency or owner? No. You get referral giveback only: no ownership and no revenue split. If you need an ownership relationship, that is a different onboarding. Contact us.
What if I don't send referral_code? Your app's bound code is used. Sending it only asserts which code you expect.
Selling on Telegram
Sell a payment link inside Telegram, without the buyer ever leaving the app. One API call returns a ready-made card, caption and Unlock button. You post them from your own bot. The buyer pays in the dropp Mini App, Telegram confirms who they are, and you get back an order.paid that carries their Telegram id and your metadata.
Use it for PPV pushes in DMs, and for broadcasts in groups or channels where you want to know which member bought. If the buyer isn't on Telegram, share the normal checkout_url from POST /v1/links instead.
How Telegram selling works
Mint a vault link for an existing payment link with POST /v1/links/{id}/vault-link. You get a share URL and a telegram kit: card image, caption, button label and button URL.
Send the card from your bot: photo, caption and one inline button.
The fan taps Unlock. The dropp Mini App opens inside Telegram with the blurred cover and the price. Telegram signs the buyer's identity at this point, so you have nothing to pass along.
The fan pays inside the Mini App.
dropp delivers the content to the buyer's Telegram DM, and fires order.paid with buyer.telegram and the metadata of the copy they bought through.
Minting a vault link
POST /v1/links/{id}/vault-link (scope links:write) mints a new tracked copy of the link and returns everything you need to post it.
Mint one vault link per push. Every call creates a new copy, and a copy's metadata comes back on every sale made through it. Minting per channel, chatter or send keeps attribution exact. With the same Idempotency-Key and body, a retry returns the original copy instead of a second one.
The fee fields customer_fee_buyer_share_percent and partner_fee_buyer_share_percent are not accepted here and return 400.
expires_at is one year after minting. Mint a fresh vault link for pushes after that.
404 means the link doesn't exist, is archived, isn't yours, or Telegram selling isn't enabled.
Agency keys: pass profile_id in the query string, never in the body. See Acting for a creator.
Sending the Telegram card
telegram.photo_url is a ready-made 1200×630 card: the blurred cover, a lock and the price. Use telegram.cover_url, the blurred cover alone, if you want to compose your own artwork. Telegram's servers fetch both URLs directly.
Post the card with the Bot API's sendPhoto, and attach telegram.button as a URL button:
button.url opens the Mini App checkout directly, so the buyer goes from the card to checkout in one tap. Use it as returned: it carries a signed token, and changing it breaks the checkout. Treat it as opaque, because it can also fall back to share_url.
caption is 🔒 plus the link name. button.text is 🔒 Unlock for plus price.display. Both are suggestions, so you can write your own.
The card and cover only ever show the blurred variant. The original media is not revealed before payment.
Bots, Telegram Business and userbots
Only a bot can attach inline buttons. A user-account session (a "userbot") can't send them, and Telegram drops the markup without an error.
Setup
Button
Notes
Your own bot messages the fan
✅
The fan must have started your bot
A bot connected to the creator's account through Telegram Business
✅
The message appears as the creator, in their real DM. Telegram Business needs Telegram Premium on the creator's account
The creator's account driven directly (userbot)
❌
Send share_url as a plain link instead
No bot? Send the link. Posted as plain text, share_url renders its own preview in Telegram (blurred cover and price) and opens the Mini App when tapped. You lose the button, not the flow.
Identifying Telegram buyers
A Mini App checkout identifies the buyer from the data Telegram signs, so the identity comes from Telegram, not from what the buyer typed. It arrives on the order.paid webhook together with the copy's metadata:
buyer.telegram.id is a string. Telegram ids can exceed 32 bits, and a string avoids precision loss in languages that parse JSON numbers as floats. Store it as a string or a 64-bit integer.
buyer.telegram.username is null when the buyer has no username. Always key on id.
buyer.telegram is null on orders that didn't go through Telegram.
It is the same id your bot uses as chat_id, so you can follow up with the exact person who bought.
The same buyer.telegram object appears on GET /v1/orders and on order.upsell_paid.
order.paid can arrive more than once. Deduplicate on the event id, as described in Retries & Delivery.
Delivering the purchased content
When the creator's Telegram account is connected to dropp, dropp delivers the unlocked photos and videos to the buyer's own DM, sent from the creator's account with forwarding disabled. dropp posts into the creator's existing conversation with that buyer, or else messages their Telegram id directly. If delivery still fails after retries, dropp alerts the creator.
If the creator's Telegram account lives only in your tool and isn't connected to dropp, dropp can't post into their chats. Deliver it yourself when order.paid arrives:
Read buyer.telegram.id and link.id from the webhook.
For each content item in that link, call GET /v1/content/{id}/download-url (scope content:read). It returns signed URLs that expire after 15 minutes.
Send the files from your bot to that id.
Node.js
// On order.paid, for a link you created with content_ids you stored
const { buyer, link } = event.data;
if (buyer?.telegram) {
for (const contentId of contentIdsByLink[link.id]) {
const res = await fetch(
`https://api.external.dropp.fans/v1/content/${contentId}/download-url`,
{ headers: { Authorization: `Bearer ${process.env.DROPP_API_KEY}` } },
);
const { data } = await res.json();
const method = data.type === "video" ? "sendVideo" : "sendPhoto";
await fetch(`https://api.telegram.org/bot${process.env.BOT_TOKEN}/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: buyer.telegram.id,
[data.type === "video" ? "video" : "photo"]: data.download_url,
protect_content: true,
}),
});
}
}
The API doesn't list which content ids a link contains: GET /v1/links/{id} returns only a count and the types. Keep the content_ids you passed to POST /v1/links.
Ask for the signed URL right before sending, because it expires after 15 minutes.
Telegram only fetches files by URL up to 5 MB for photos and 20 MB for other files. For larger videos, download the file and upload it to the Bot API as multipart/form-data.
Telegram selling limitations
Only payment links can be sold in the Mini App. Donation pages and wishlists aren't supported there yet, so share their own checkout URLs.
A vault link and its button stop working after expires_at, one year after minting.
Inline buttons need a bot. Userbots can only send share_url as a plain link.
Automatic delivery in the chat needs the creator's Telegram account to be connected to dropp. Otherwise you deliver the content yourself.
Embed Checkout (SDK)
The embed SDK renders a dropp payment form, and optionally a funnel's upsell steps, directly on your own page. You design the page and place the fields; dropp's payment provider hosts the card input in its own frame, so card numbers never touch your code. Use it to sell a payment link (link_…) with your own layout, pass your own tracking data through to webhooks, and run one-click upsells after the first purchase.
For a boost, roses or payment-link checkout you can drop in with two lines and no custom form, use the Donation & Payment Widget instead. The two are separate scripts and can share a page.
Approve your domain first
The SDK only talks to dropp from domains the creator has approved. Add the exact origin of your page (for example https://yoursite.com) under Settings → Embedding in the dashboard. For the SDK, either an account-wide origin or one scoped to the link you sell works. A page on an unapproved domain gets 403 with EMBED_ORIGIN_NOT_ALLOWED when it opens the order, and no form appears. See Allowing your website for the details, which are the same for both scripts.
Loading the SDK
Load the script and stylesheet once per page. On site builders (Webflow, Framer, systeme.io), put them in the site's footer or custom-code slot, because builders don't run scripts pasted inside a content block.
The script exposes window.Dropp with embed(), upsell.accept(), upsell.decline() and version.
Markup-only install
If you can't run your own script, paste the checkout markup and the SDK starts itself. It looks for .dropp-checkout[data-dropp-product] and expects the fields under these exact ids. Settings → Embedding → Get embed code generates this for you.
The SDK waits up to 3 seconds for your selectors to appear, for builders that render late. After that, a missing element is reported through onPaymentError with code: "invalid_config", and in the console. embed() itself does not throw.
fields.cardNumber, cardExpiry and cardCvv still work for old snippets but are deprecated: use the single card field.
The creator's brand colour is applied to .dropp-checkout through the --dropp-accent and --dropp-focus CSS variables.
Payment errors
onPaymentError receives { code, message }. When you don't register it, the SDK shows a localized error banner at the top of .dropp-checkout instead.
code
Meaning
declined
The card was refused. The buyer can try again
authentication_failed
The bank's 3-D Secure check was closed or never completed. Nothing was charged. The buyer can approve in their banking app, turn off a VPN, or use another card
payment_form_unavailable
The card form script failed to load twice (15 s timeout, one retry). Ask the buyer to refresh
invalid_config
Developer error: a missing option or selector, or invalid metadata. Console only, never shown to the buyer
Anything else
Another payment or setup failure (for example payment_error or unknown). Log message
Passing your own data (client_metadata)
Attach your own ids (user id, click id, campaign, A/B variant) to a checkout and get them back on the order's webhooks as client_metadata.
Limits: a flat map of string values only, max 20 keys, keys ≤ 64 characters, values ≤ 500 characters, ≤ 2048 bytes serialized. The server enforces the same limits. Don't confuse this with link-copy metadata: see the three metadata fields.
Every upsell the buyer accepts afterwards inherits the order's client_metadata, including on separate upsell landing pages.
Invalid metadata passed to Dropp.embed() stops the checkout from mounting (invalid_config). On a markup install, an attribute that is not valid JSON is ignored and the checkout still loads. Valid JSON with a non-string value ({"a": 42}) is rejected like the JS option, so the form does not mount.
Resolving metadata at pay-click
If the values come from a form the buyer fills after the checkout loads, pass a function. It is called when the buyer clicks pay, and the map it returns replaces the order's metadata just before the charge.
Return undefined or {} to keep the values already on the order.
A resolver that throws or returns an invalid map, or an update that fails on the network, is skipped: the payment still goes through with the previous metadata. Turn on debug logging to see why.
Funnels and upsells in the embed SDK
When the link has an upsell funnel, the SDK takes over after the first payment. What happens depends on the funnel's render mode (set in the funnel editor) and on your upsell option.
Funnel render mode
upsell option
Behaviour
Same page
{ mode: 'dropp-rendered', target }
dropp renders each offer inside target, with its own accept and decline buttons
Same page
{ mode: 'headless', confirmation? }
You render the offers and call Dropp.upsell.accept() / decline()
Same page
{ mode: 'none' } or omitted
No upsells on this page. onComplete fires right after payment
External pages
any
The SDK redirects the buyer to the first step's landing page
Every step charges the card saved during the first payment. The buyer never re-enters it.
Rendered mode
Node.js
Dropp.embed({
// …
upsell: {
mode: 'dropp-rendered',
target: '#dropp-upsell-target',
// Optional: called when the buyer accepts a step.
stepMetadata: (step) => ({ ab_variant: 'b', position: String(step.position) }),
},
});
stepMetadata returns a map with the same limits as client_metadata. It is stored on that upsell and sent only on its order.upsell_paid webhook as step_metadata. A resolver that throws or returns an invalid map is skipped and never blocks the charge.
Headless mode
Render the offer yourself from onStepChange, then report the buyer's choice:
step.offer.price is in major units (4.99), unlike amountCents on the result below.
By default the SDK shows its own confirmation overlay (card brand, last 4 digits, amount) before charging. confirmation: 'manual' skips it; you must then show your own confirmation before calling accept().
accept() and decline() only work on the current step, and one call at a time: a second call while one is running throws UPSELL_IN_FLIGHT.
Invalid metadata on accept() throws before anything is charged.
Upsell results
Dropp.upsell.accept() resolves with:
Field
Meaning
status
charged, cancelled, declined, authentication_failed or error
charged
true only when status is charged
amountCents
The step's price
retryable
true when calling accept() again on the same step is the right next move
nextStep
The step to show next, or null. After a retryable failure it is still the step you just attempted
status
What happened
retryable
charged
Paid. The funnel moved on
false
cancelled
The buyer closed the SDK's confirmation overlay. Nothing was charged
true
declined
The card was refused. The funnel moved on (to the downsell or the next step)
false
authentication_failed
The bank verification failed, was closed or timed out. Nothing was charged
true
error
A provider or dropp error
true
onUpsellPaymentFailed({ stepId, isDownsell, reason, retryable, message }) fires once per failed attempt, in both rendered and headless modes, just before accept() resolves. reason is authentication_failed, declined or error. message is provider text for your logs: don't show it to the buyer.
onComplete receives the run's summary: totalCharged (cents, first payment plus accepted upsells), currency, accepted and declined (step ids), parentOrderId and isTest.
Upsell landing pages (external pages mode)
In this mode each step is a page you build. After the first payment the SDK sets a dropp_session cookie on your domain (30 minutes) and redirects to step 1. Load the SDK on every step page and add the step's buttons:
data-dropp-step is the 1-based step position. Add data-dropp-variant="downsell" on a downsell page. The dashboard generates these snippets for each step.
data-dropp-metadata is optional and becomes that step's step_metadata.
Tracking parameters on the checkout URL (utm_*, click ids…) are carried onto every step and the thank-you page.
Buyers who block cookies get the session in the URL (?dropp_session=) and see a short notice.
Each decision fires a dropp:upsell-step-decided event on window with stepId, stepPosition, accepted, isDownsell, amountCents and currency, for your analytics.
3-D Secure on upsells
A creator can require bank verification on every upsell and downsell of a funnel: Funnel → Settings → Require bank verification (3-D Secure). It is off by default. Your integration does not change either way: the SDK reads the setting and runs the flow.
On accept, the SDK runs the bank's device check in a hidden frame (up to 10 seconds), then places a hold on the card instead of charging it.
If the bank asks the buyer to verify, the SDK shows the bank's page in its own overlay, in every mode, including headless. You cannot render it yourself. In headless mode the accept() promise stays pending until the buyer finishes.
The hold is captured only when the bank authenticated the buyer. In every other case the hold is released, nothing is charged, and the buyer stays on the same offer: authentication_failed, retryable: true. Rendered mode shows a Retry button; landing pages re-enable the accept button with an inline notice.
A failed or abandoned verification sends no webhook.
The trade-off: verification moves chargeback liability to the bank, but some buyers abandon at the bank's step. Try it on one funnel first.
Testing the embed SDK
A funnel with Test mode on (in the funnel's settings) charges against the payment provider's test environment: test cards work and no money moves.
The checkout shows a TEST MODE — no real charges banner.
onPaymentSuccess and onComplete carry isTest: true. Filter these out of your own conversion tracking (pixels, analytics).
Test orders send order.paid / order.upsell_paidonly to webhook endpoints registered with a test key, with livemode: false; your live endpoints never receive them. To check a webhook handler on a test run, register an endpoint with a test key.
To test 3-D Secure on upsells, price the test offer above 100.00. The test environment only authenticates amounts above 100.00; at 100.00 or below every attempt ends in authentication_failed. This rule applies to the test environment only.
Getting results on webhooks
The browser callbacks are for your page's UI. To grant access, fulfil or record a sale, use webhooks: they are signed and can't be faked from a browser.
order.upsell_paid is a separate event type: subscribe your endpoint to it, or upsell sales won't reach you. step_metadata never appears on order.paid.
Join the two with parent_order.id. Don't assume order.paid always arrives before a fast order.upsell_paid.
The orderId from onPaymentSuccess is the data.id of order.paid.
Debugging the embed SDK
The SDK is quiet by default: only errors reach the console. Turn on a timestamped timeline of every step (order opened, card form loaded, pay clicked, funnel state, 3-D Secure, redirects) in any of these ways:
open the page with ?dropp_debug=1: the flag is kept in localStorage so it follows the buyer through the funnel's redirects;
run localStorage.dropp_debug = '1' in the console and reload;
set window.DROPP_DEBUG = true before the SDK script;
pass debug: true to Dropp.embed().
Turn it off with localStorage.removeItem('dropp_debug'). Every line starts with [dropp].
If your page sends a Content-Security-Policy, it must allow the SDK's script and stylesheet from https://app.dropp.fans, the card form from https://cdn.safecharge.com, requests to dropp's payment API, and frames from the payment provider and the buyer's bank. Run a checkout with debug logging on and look for blocked requests in the console.
Donation & Payment Widget
The widget puts a dropp boost, roses or payment link checkout on your own website with two lines of HTML. The fan pays without leaving your page. The checkout is a dropp-hosted page inside an <iframe>, so card data never touches your site. The widget sizes itself, uses the creator's brand colour, and tells your page when a payment lands.
Widget or embed SDK?
You want to sell…
Use
A boost or roses: the fan chooses the amount
This widget, data-dropp-donation or data-dropp-flower
A payment link at its fixed price, with dropp's checkout form
This widget, data-dropp-link
A payment link with your own form layout, client_metadata, or funnel upsells on your own page
They are separate scripts (/widget/v1.js and /embed/v1.js) with separate globals (DroppWidget and Dropp), and you can use both on one page.
Allowing your website
A dropp widget only loads inside websites the creator has approved. Browsers enforce this: the widget page is served with a Content-Security-Policy: frame-ancestors header listing the approved origins, so an unapproved site is refused by the browser itself.
In the dashboard, open Settings → Embedding (direct link: /dashboard/creator/<profile-id>/settings?tab=embedding). Agency admins find the same tab in a managed creator's settings.
Click Add origin and enter the exact origin of your site, for example https://yoursite.com: scheme and host only, no path. https:// is required (http://localhost is allowed for development).
Leave Scope on All links (account-wide) and click Add.
Add one origin per site. https://yoursite.com and https://www.yoursite.com are two different origins. After adding one, allow up to a minute before the widget loads there.
The Pending review / Verified status on a row does not affect widgets.
Adding the widget
Paste the loader once per page, ideally just before </body>, and a container wherever the checkout should appear. Settings → Embedding → Widgets generates both for each of your links.
One container carries exactly one of the three attributes.
Several widgets can share a page, for example a boost button next to a product. Each works on its own.
Containers added to the page later (single-page apps, lazy blocks) are mounted automatically. A container removed from the page is cleaned up.
On site builders (Webflow, Framer, systeme.io), put the <div> in an Embed / Raw HTML block and the loader in the site's footer code area. Builders don't run scripts pasted inside a content block.
Widget options
All optional, set on the container.
Attribute
Effect
data-dropp-locale
Checkout language: en, fr, de, es, it, pt or ru. Anything else falls back to en
data-dropp-height
Height in pixels shown before the checkout loads (default 420). Set it close to the real height so the page doesn't jump
data-dropp-layout="form"
Payment-link widget only: the checkout form alone, without the product image, title and description. The order summary still names the product and price. Off-switch in the dashboard: Show product details
You don't need to set a height for normal use. The widget reports its content height as the fan moves through the form, so there is no inner scrollbar. Don't give the container a fixed height or overflow: hidden.
Boosts, roses and payment links
Boosts and roses (don_lk_…) let the fan choose the amount. Boost and roses orders are never test orders.
Payment links (link_…) sell the link at its price. The widget never sends an amount: the price is read from the link on dropp's side, so nothing on your page can change what the fan pays.
After paying, the fan sees a confirmation with an Open my purchase button. It opens their order page (downloads, and the link's upsell offers if it has a funnel) in a new tab. The widget itself never navigates your page.
Upsell steps don't run inside the widget. If the link has a funnel, the form shows the card-on-file consent line, because the order page runs those offers. To run upsells on your own page, use the embed SDK.
Links with an auction or a raffle attached can't be embedded: the browser refuses the frame and the widget shows its unavailable notice.
A link whose funnel is in test mode produces test orders (isTest: true), which send no webhooks.
Selling through a tracked copy
If you mint link copies through the API, one per fan or campaign, embed the parent id and pass the copy's slug as the ref:
The sale is attributed exactly as if the fan had opened the copy's own URL: order.paid carries the copy's metadata, and the copy's fee settings apply. It works for all three kinds. For donation copies, a per-copy minimum amount also applies.
The slug is the last part of the copy's URL (app.dropp.fans/s/<slug>): letters, digits, _ and -, up to 64 characters. Any other value (a copy id, a full URL) is dropped: the sale still completes, but without attribution. Check the webhook of your first test sale.
The ref must belong to a copy of the link you embed.
Approved origins are per account or per link, never per copy: every copy is covered by the origin you already added.
The browser event never carries the ref. Learn which copy sold from the order.paid webhook, and join it to the browser event on orderId if you need both.
The dropp:widget:complete event
When a payment succeeds, the widget dispatches dropp:widget:complete on its container. It bubbles, so one listener on document sees every widget on the page.
The dropp order id, the same data.id as on the order.paid webhook
amountCents
What the fan was charged, fees and VAT included, in cents
currency
For example EUR
isTest
true for a test order. Always false for boosts and roses
kind
donation, flower or link
product
The id from your container, so a page with several widgets knows which one sold
kind and product come from your own snippet, not from the checkout. The event never contains the buyer's email or any other buyer data.
Mounting the widget from JavaScript
The loader mounts every matching container on its own. To control mounting yourself, call DroppWidget.mount():
Node.js
const widget = DroppWidget.mount({
kind: 'donation', // 'donation' | 'flower' | 'link'
product: 'don_lk_xxxxxxxxxxxxxxxxxx', // a link_… id with kind: 'link'
target: '#my-slot', // selector or element
locale: 'fr',
initialHeight: 500,
layout: 'form', // kind: 'link' only
ref: 'my-copy-slug', // optional tracked-copy slug
onComplete: (result) => console.log(result), // same fields as the event
onUnavailable: () => showFallback(), // blocked, not found or offline
});
widget.isReady(); // true once the checkout answered
widget.destroy(); // removes the frame and stops listening
The event is still dispatched when you pass onComplete. DroppWidget.completeEvent holds the event name and DroppWidget.version the bundle version.
Content Security Policy for the host page
If your page sends its own Content-Security-Policy, allow the loader script and the widget frame:
Merge these into your existing directives rather than replacing them. If you set child-src and no frame-src, add https://app.dropp.fans there.
The widget needs no'unsafe-inline' for styles: it only sets styles through script properties, which a strict style-src allows.
If you send a Permissions-Policy header, don't disable payment for https://app.dropp.fans, or the Apple Pay button won't appear in the frame.
The card form, and a bank's 3-D Secure page, load inside the dropp frame, so your policy doesn't need to list the payment provider or banks.
Bank verification (3-D Secure). When a bank challenges a payment, the widget temporarily covers the whole viewport so the fan can complete it, then returns to its size. Don't place the widget inside an element with a CSS transform, filter, perspective, backdrop-filter or contain: paint: those trap the full-screen overlay inside the box. The loader logs a console error when it finds one.
Troubleshooting the widget
Open your browser console on your own page first. Most problems are named there. Warnings about data-dropp-locale, data-dropp-layout and data-dropp-ref only show with debug logging on: open the page with ?dropp_debug=1.
What you see
Cause and fix
A box that says "This payment form is unavailable right now", after about 15 seconds
The widget never answered. Almost always the site is not approved, or was approved with the wrong scope. Check Settings → Embedding: the row's Scope must say Account-wide for boosts and roses, and the origin must match exactly, www. included. The console shows widget: no response from …
Console mentions frame-ancestors
Same cause: the browser refused the frame because the origin is not approved
You just added the origin and it's still blocked
Wait a minute and reload: the approved list is cached briefly
Nothing at all, on a site builder
The loader was pasted into a content block, where builders don't run scripts. Move it to the footer code area
Console: container has an empty product id
The data-dropp-donation, data-dropp-flower or data-dropp-link attribute has no id in it
Console: … — keep one. Skipping.
One container carries more than one of the three attributes. Keep one
A payment-link widget is unavailable although the site is approved
The link is inactive or archived, or has an auction or raffle attached. Those can't be embedded
The frame loads but shows an error
The boost, roses or payment link is no longer active. Check it in the dashboard
Blocked by your own page's policy (console mentions script-src or frame-src)
The bank verification appears squeezed inside the box
An ancestor uses transform or filter. Move the container out of it
The sale completed but order.paid has no copy metadata
data-dropp-ref is not the copy's slug (for example its id or full URL was pasted), so it was dropped. Use the last part of app.dropp.fans/s/<slug>
isTest is always false on boosts
Expected: boost and roses orders are never test orders
Account & Creators
Start every integration with two calls: GET /v1/me to learn who your credential acts as, and GET /v1/creators to list the creators you can act for.
Who am I acting as? (GET /v1/me)
GET /v1/me requires no scope — any valid credential may introspect itself. The field you want is effective_profile_id, not profile.id. For a solo creator they happen to be equal, which is why reading the wrong one only breaks later.
API-key callers only (id, prefix, scopes). Always null on an OAuth token
app
OAuth and app-key callers only. Carries multi_creator, which decides the scoping rules
profile
The identity that owns the credential. Not necessarily who your calls act on
agency
Set for agency API keys. Always null on an OAuth token
acts_as
The creator the credential is bound to act for, if any
effective_profile_id
The profile every operation actually runs as. Nullable — null on a multi-creator install until a request names a creator
connected_creators
OAuth installs only. Every creator this install can act for
On each connected_creators row, filter on status === "active" to know what you can call, and read granted_via_agency (null when the creator connected you themselves, { id, name } when an agency admin delegated them) to know where it came from. scopes_stale: true means the grant predates scopes your app gained later — the creator must reconnect.
Who can I call? The four cases
Mode
Who authorised
effective_profile_id
connected_creators
Send profile_id?
Single-creator
The creator, for themselves
The creator
One active row
Optional
Single-creator
An agency admin, delegating other creators
None — scoped calls fail with 403 no_active_grant
Rows stay pending
—
Multi-creator
The creator, for themselves
null
One active row
Required
Multi-creator
An agency admin, delegating a roster
null
N active rows, granted_via_agency set
Required
On a single-creator app, a delegated grant is recorded but stays pending until your app is switched to multi-creator — ask us for the switch, or have the creator connect themselves.
Which creators can I act for? (GET /v1/creators)
No scope required. Use it to build a creator picker, and use each id as the profile_id on writes. What you get back depends on the key:
Key type
Returns
Agency key
The agency's creators, plus the agency account itself (is_self: true)
Creator-bound key
Exactly that one creator
Personal key
Just the key owner
Don't infer creators from the links endpoint. A creator who hasn't published anything owns no links, so a brand-new agency would appear to have no creators at all. This endpoint is independent of content: creators appear from day one.
display_name is always populated (falling back name → username → placeholder), so it is safe to render directly. Most creators have no username, so never build a label from username alone.
Creating creator accounts — two endpoints, two callers
Both create a real person's account and email them an invite. Pick by who you are:
You are…
Endpoint
What you get
An agency onboarding your own talent
POST /v1/agency/creators — agency Bearer key + creators:write
The creator joins your agency on a 100/0 split. You can own links, content and wishlists for them immediately
An affiliate partner app referring a new seller
POST /v1/creators — X-Dropp-App-Key, no Bearer, creators:provision
A standalone account carrying your referral code. You earn giveback on their sales, and your app can act for them. No agency, no split
Onboarding onto your agency
Links, donation pages and wishlists can only be owned by a creator profile — your agency's own profile can never own one. POST /v1/agency/creators creates that creator, usable immediately: it appears in GET /v1/creators and can own content before the person has ever logged in. They receive an email inviting them to take the account over.
Agency keys only. The creator is always created in the key's own agency. Creator-bound keys and OAuth apps get 403 unsupported_credential, and the key's owner must be an ADMIN or MANAGER of the agency.
Keys minted before this scope existed don't carry it. Create a new key with creators:write ticked.
The revenue split starts at 100% agency / 0% creator, exactly like a dashboard invite. Agencies change it in the dashboard — there is no split API.
409 email_unavailable is terminal for that address, most often because it already has a dropp account. Invite them from the dashboard instead. Don't iterate over address variants: rejections carry their own tight budget.
Retries are safe. The same address twice returns the same creator with "status": "existing" and sends no second invite.
Budgets are per agency: 30/hour and 200/day (see Rate Limits).
Provisioning a referred seller
For approved affiliate partners, not for agencies onboarding their own talent — the referral guide covers it end to end. POST /v1/creators authenticates your app, not a user: send X-Dropp-App-Key and noAuthorization header — there is no creator behind the call yet. It requires a referral code bound to your app; the code is taken from that binding, never from the request.
Affiliate model, not agency membership. The account is an independent seller with your referral attribution attached at signup. No agency membership and no revenue split are created.
type: "AGENCY" (with agency_name) creates an agency account instead, with the invited person as its ADMIN.
Idempotent on email. An address that already has a dropp account is returned untouched with status: "already_exists" and is never re-attributed.
The person takes ownership themselves by email and cannot sell until KYC is complete.
Acting for the creators you provisioned
Provisioning also creates an install: a grant that lets your app act as that creator with the same X-Dropp-App-Key and no Bearer token. Name the creator with profile_id and call any endpoint your app's scopes allow:
Only creators your app provisioned. A creator who connected through Login with dropp is reached with their OAuth token, and an address that returned status: "already_exists" gets no install.
Name the creator on every call — profile_id in the body for writes, in the query for reads. An unnamed request is 400 ambiguous_scope.
The creator can revoke you from Settings → Connected Apps; every later call returns 403 profile_not_connected.
Scopes are the intersection of your app registration and the install.
The roster behind the key: a creator-bound key returns exactly its creator, an agency key returns the agency's creators plus the key owner, and a personal key returns just itself. Independent of whether those creators own any links, orders or content yet — use it to build a creator picker at connection time.
Creates a standalone seller account — a creator (default) or an agency (type: "AGENCY" with an agency_name) — and attaches YOUR referral code (bound to your app) at account birth, then emails the person an invite so they take ownership (dropp is passwordless). Affiliate model: you get referral giveback on their future sales, not ownership — no revenue split is created. An agency account gets its own agencies record with the invited person as ADMIN. Requires the creators:provision scope and a referral code bound to your app. Idempotent on email: an existing account is returned untouched and never re-attributed. Send an Idempotency-Key header to make retries safe.
Headers
Field
Type
Description
X-Dropp-App-Keyrequired
string
Your partner application key (drp_app_live_...). This endpoint authenticates the APP itself — send NO Authorization Bearer, there is no creator behind the call yet.
Request body
Field
Type
Description
type
string
The kind of seller account to create. CREATOR (default) is an individual creator. AGENCY creates an agency account (its own agencies record with the invited person as ADMIN) — use it when the account you are referring will manage other creators. Either way this is affiliate-only: you get referral giveback, not ownership.One of CREATOR, AGENCY
agency_name
string
The agency name. Required when type is AGENCY, ignored otherwise.
emailrequired
string
The new creator's email. An invite is sent here so the human can take ownership of the account (dropp is passwordless — the invite is how they log in). Normalised to lowercase to match how accounts are keyed.
first_name
string
last_name
string
referral_code
string
The referral code to attribute this creator to. OPTIONAL and, when sent, must equal your app's own bound code — you cannot attribute a creator to another partner's code. Omit it to use your bound code.
Creates a creator account inside YOUR agency and emails the person an invitation to take it over. The creator is usable immediately: it appears in GET /v1/creators and can own links, donation links, wishlists and content straight away, before they log in.
Requires an agency API key with the creators:write scope whose owner is an ADMIN or MANAGER of that agency. The agency is always the key's own — there is no agency_id input. Creator-bound keys and OAuth-connected applications cannot call this.
The new creator starts on a 100% agency / 0% creator revenue split, exactly like a dashboard invite; change it from the dashboard.
Not to be confused with POST /v1/creators, which is for affiliate partner apps: that one mints a standalone creator carrying the app's referral attribution and joins no agency.
Headers
Field
Type
Description
Idempotency-Key
string
Unique key so retries replay the original response instead of creating a duplicate. Strongly recommended for create endpoints.
Request body
Field
Type
Description
emailrequired
string
Where the invite is sent. Lowercased and trimmed before use. Must not already belong to a dropp account outside your agency.
Links are built from content you upload first. You never send file bytes through the API: you ask for a one-time upload URL, send the file straight to our media provider, then use the returned content_id in POST /v1/links. Content is stored privately. To read it back, you ask for short-lived signed URLs.
Requesting an upload URL
POST /v1/content/upload-url (scope content:write) creates the content record and returns where to send the bytes.
upload_protocol tells you how to send the file: basic or tus.
uid and upload_budget are returned for videos only. upload_budget shows what is left after this upload.
upload_url is single-use and expires quickly, so request it right before you upload.
Basic uploads
Without size_bytes, you get a basic URL. Send the whole file in one multipart POST with a file field. Images always use this protocol.
cURL
curl -X POST "$UPLOAD_URL" -F "file=@set-01.jpg"
Basic video uploads are capped at 200 MB. Sending tus PATCH requests to a basic URL fails.
Resumable video uploads (tus)
For videos over 200 MB, or whenever you want to upload in chunks and resume after a network failure, pass the exact file size as size_bytes. The response then returns "upload_protocol": "tus", and upload_url points to a tus upload that already exists.
Because the upload is already created, give the URL to your tus client as the upload URL, not as the creation endpoint:
Node.js
import * as tus from "tus-js-client";
const upload = new tus.Upload(file, {
uploadUrl: data.upload_url, // not `endpoint`
chunkSize: 50 * 1024 * 1024, // a multiple of 256 KiB
retryDelays: [0, 3000, 10000, 30000],
onError: (err) => console.error("upload failed", err),
onSuccess: () => console.log("uploaded", data.content_id),
});
upload.start();
size_bytes must match the real file size. The tus upload is created with that length.
Videos can be up to 1 hour long, with either protocol.
Waiting for video processing
Images are usable as soon as the upload finishes. Videos transcode asynchronously. Poll GET /v1/content/{id} (scope content:read) every few seconds until status is ready, and only then attach the video to a link.
A video is processing until it has a playback id, then ready.
Images, and legacy audio and pdf items, always read ready. An image is ready as soon as its upload URL is issued: dropp can't tell whether you actually sent the bytes. Only use the content_id of an image whose upload succeeded.
The content object
GET /v1/content and GET /v1/content/folders/{id} return full content objects. GET /v1/content/{id} returns only id, type, status and created_at.
image, video, audio or pdf. You can only upload images and videos through the API
name
The filename sent at upload, or null
is_archived
true after DELETE /v1/content/{id}. Archived content is hidden from lists unless you pass include_archived=true. Links and orders that already use it keep working
media.cf_uri
Image reference (image:<id>). null for videos
media.stream_uid
Video reference. null for images
media.playback_id
Set once a video finishes processing. null means still processing
media.duration / width / height
Seconds and pixels, when known
media.content_type
The MIME type sent at upload, or null
The media references are identifiers, not playable URLs: content is stored privately, so you can't embed a video with stream_uid or playback_id alone. Use the signed URLs below.
Playing and downloading content
GET /v1/content/{id}/download-url (scope content:read) returns signed URLs for content you own. They are valid for 15 minutes, so request a fresh set each time instead of storing them.
Video:playback_url is a signed HLS manifest. Give it to any HLS player to stream or embed the video. download_url is the MP4. The first time you download a video, the MP4 can take a short moment to become available.
Image:playback_url is null, and download_url is a signed image URL.
409 means the video is still processing. 400 means the type can't be downloaded (audio, pdf). 404 means the content doesn't exist or isn't yours.
Retrying uploads and creates safely
POST /v1/content/upload-url does not support Idempotency-Key. Each call creates a new content record and, for videos, uses budget. If a request times out, check GET /v1/content before you ask again.
The create calls that come next — POST /v1/links, POST /v1/links/{id}/copies and POST /v1/links/{id}/vault-link — do accept an Idempotency-Key header. Send a unique value, up to 255 characters, for each logical create:
Retry with the same key
Result
Same body, first request succeeded
The original response is replayed with the same status and the header Idempotent-Replay: true. Nothing new is created
Different body
409 conflict
First request still in progress
409 conflict. Wait and retry with the same key
First request failed
The key is released, so the retry runs normally
Keys are scoped to your credential, so two API keys can use the same value independently.
Bodies are compared by content: key order and whitespace don't matter.
A request still in progress after 10 minutes is treated as abandoned, and the next retry with that key runs again.
The connected creator to scope this read to. REQUIRED for OAuth installs in multi-creator mode (else 400 ambiguous_scope); ignored by single-creator installs and API keys, which are already scoped.
type
string
Filter by content typeOne of image, video, audio, pdf
The connected creator to scope this read to. REQUIRED for OAuth installs in multi-creator mode (else 400 ambiguous_scope); ignored by single-creator installs and API keys, which are already scoped.
Returns a one-time Cloudflare direct-upload URL and creates the pending content record. Upload the file bytes straight to the URL; videos finish processing asynchronously (poll GET /v1/content/{id}). For videos over 200 MB (or to chunk/resume), pass size_bytes: the response then returns upload_protocol: "tus" and a resumable upload URL to drive with the tus protocol. Without size_bytes you get a basic single-POST URL (200 MB max).
Request body
Field
Type
Description
typerequired
string
Kind of content to upload.One of image, video
profile_id
string
Creator profile that will own the content. Defaults to the API key owner. Acting as another profile requires an agency-scoped key and that profile being a CREATOR in the agency.
filename
string
Original file name.
content_type
string
MIME type of the file being uploaded.
size_bytes
integer
Total file size in bytes. Required to get a resumable (tus) upload URL for videos — pass it for any video over 200 MB, or whenever you want to chunk/resume. When provided for a video, the response returns upload_protocol: "tus" and an upload_url you drive with the tus protocol (PATCH chunks). Omit it for a basic single-POST upload (≤ 200 MB). Ignored for images.
Returns short-lived signed URLs to play (HLS, videos) and download (MP4 for video, image delivery URL for images) content you own. Content is stored privately, so these signed URLs are the only way to fetch the bytes. Videos must have finished processing (otherwise 409).
Soft-deletes (archives) content you own. Idempotent. The underlying Cloudflare asset is kept, so links/orders that already reference it keep working; archived content is hidden from GET /v1/content.
Payment links sell uploaded content at a fixed price. Every link is shared through copies: each copy has its own slug, checkout_url and optional metadata, so you can track which fan, conversation or campaign each sale came from.
Live and test links — a link carries livemode, set from the key that created it and never changed. A link created with a test key checks out on the payment provider's sandbox and is only ever visible to test keys.
Link folders — creators organize links into folders in the app. Agencies can list folders and sync only the ones they select: filter links to a folder with GET /v1/links?folder_id=link_clt_....
Tracking fans & attribution (metadata)
Attach an opaque JSON metadata object to a link copy, and dropp echoes it back when a fan opens the link and when they buy.
Metadata lives on a link copy — the value identified by the slug — not on the URL itself. Adding your own query params to a share URL is not captured.
Set it when you mint a copy with POST /v1/links (creates a link and its first copy) or POST /v1/links/{id}/copies (mints another copy of an existing link).
Share the returned checkout_url. You then receive the exact same metadata on the link_copy.opened and order.paidwebhooks and on GET /v1/orders.
Copies made with the dropp app's "copy link" button carry no metadata, so their orders arrive with metadata: null.
Limits: JSON object only (not array or null), max 8 KB, 5 levels deep, 100 keys, keys ≤ 128 characters, string values ≤ 2048 characters.
Two other metadata fields exist — don't conflate them with link-copy metadata:
client_metadata — set by embed-SDK integrations when initializing a checkout. A flat string-to-string map: max 20 keys, keys ≤ 64 characters, values ≤ 500 characters, ≤ 2048 bytes serialized. Echoed on order.paid and order.upsell_paid.
step_metadata — attached to a funnel step; echoed only on order.upsell_paid. Same limits as client_metadata.
Crediting a team member (operator_profile_id)
metadata is yours and opaque — dropp never reads it. To make a sale also count for a specific team member (a chatter) in dropp's own agency stats, name them with operator_profile_id on any copy-minting call: POST /v1/links, POST /v1/links/{id}/copies, POST /v1/donation-links/{id}/copies and POST /v1/wishlists/{id}/copies.
The id must be an ADMIN or MANAGER of an agency the creator belongs to. Anything else is rejected with 400 operator_not_in_scope rather than silently falling back.
Omit the field and the copy is credited to the creator.
Who pays dropp's fee, per copy
Every copy-minting call accepts two optional fields that decide who bears dropp's fees on sales through that copy. They are not metadata: "fee": "split" inside metadata is echoed back and never reaches the checkout.
Field
Values
Meaning
customer_fee_buyer_share_percent
0 · 50 · 100
Share of the customer fee the buyer bears. 0 = the seller absorbs it, 50 = split 50/50, 100 = the fan pays all of it on top
partner_fee_buyer_share_percent
0 · 50
Share of the partner fee (seller-side commission) the buyer bears on top. 0 = the seller bears it all (the default). There is no 100
Per copy only. The link and every other copy are unchanged. Omit both fields and the seller's account setting, or their agency's fee deal, decides.
An explicit value also applies inside an agency fee deal. Under such a deal the partner fee is already 0, so partner_fee_buyer_share_percent has no effect there.
Upsells follow the copy. A fan who bought through a copy with an explicit value gets the same rule on that funnel's upsells.
Not accepted on POST /v1/links/{id}/vault-link.
Every copy the API mints comes back with a fees block: what you stored, and under effective, what the next checkout will actually use, with *_decided_by naming what decided it (copy, seller_default, agency_fee_deal, campaign or funnel). effective is null when the seller's fee context could not be read at mint time.
The connected creator to scope this read to. REQUIRED for OAuth installs in multi-creator mode (else 400 ambiguous_scope); ignored by single-creator installs and API keys, which are already scoped.
is_active
boolean
Filter by active status
created_after
string
Filter links created on or after this date (ISO-8601)
created_before
string
Filter links created on or before this date (ISO-8601)
folder_id
string
Only return links inside this folder (link_clt_ id)
Atomically creates a payment link from already-uploaded content and mints a shareable copy carrying opaque metadata. Returns the buyable checkout URL. The metadata is echoed back on link_copy.opened and order.paid webhooks and on GET /v1/orders.
Headers
Field
Type
Description
Idempotency-Key
string
Unique key so retries replay the original response instead of creating a duplicate. Strongly recommended for create endpoints.
Request body
Field
Type
Description
customer_fee_buyer_share_percent
integer
Who pays dropp's CUSTOMER fee on sales through this copy, as the share (%) the BUYER bears: 0 = the seller absorbs it (the buyer pays the listed price), 50 = split 50/50, 100 = the buyer pays all of it on top. Omit to keep the seller's normal behaviour (their account setting, or their agency's fee deal). An explicit value applies to this copy only, and also inside an agency fee deal. Any other value is a 400.One of 0, 50, 100
partner_fee_buyer_share_percent
integer
Who pays dropp's PARTNER fee (the seller-side commission) on sales through this copy, as the share (%) the BUYER bears on top of the price: 0 = the seller bears it all (the default, same as omitting the field, and stored as null), 50 = split 50/50. There is no 100. Independent of the customer dial. Accepted but without effect for a seller under an agency fee deal, where the partner fee is 0: see the effective values in the response. Any other value is a 400.One of 0, 50
profile_id
string
Creator profile that will own the link. Defaults to the API key owner. When set to another profile, the key must be agency-scoped and the profile must be a CREATOR in that agency.
operator_profile_id
string
dropp profile id of the team member the auto-minted first copy is for. Sales through it are credited to them in the agency chatter stats. Must be an ADMIN or MANAGER of an agency the creator belongs to. Omit to credit the creator, which is the previous behaviour.
content_idsrequired
string[]
IDs of already-uploaded content to attach (in order).
pricerequired
integer
Price in minor units (cents).
currency_code
string
ISO 4217 currency code. Omit to use the creator's own selling currency (their dashboard setting) — send it explicitly only when the price you are passing is denominated in that currency.One of EUR, USD, GBP, CAD, CHF, JPY, AUD, SEK, NOK, DKK
metadata
object
Opaque JSON object echoed back on link_copy.opened and order.paid webhooks and on GET /v1/orders. Max 8 KB, 5 levels deep, 100 keys.
The connected creator to scope this read to. REQUIRED for OAuth installs in multi-creator mode (else 400 ambiguous_scope); ignored by single-creator installs and API keys, which are already scoped.
Creates a new shareable copy (own slug + metadata) of an existing link you own — reuse a link instead of recreating it. Optionally choose who pays the dropp fees on sales through THIS copy (customer_fee_buyer_share_percent: 0 = seller, 50 = split, 100 = buyer; partner_fee_buyer_share_percent: 0 | 50). The response returns the stored values and the effective ones the next checkout will price with (copy.fees).
Path parameters
Field
Type
Description
idrequired
string
Link ID
Headers
Field
Type
Description
Idempotency-Key
string
Unique key so retries replay the original response instead of creating a duplicate. Strongly recommended for create endpoints.
Request body
Field
Type
Description
customer_fee_buyer_share_percent
integer
Who pays dropp's CUSTOMER fee on sales through this copy, as the share (%) the BUYER bears: 0 = the seller absorbs it (the buyer pays the listed price), 50 = split 50/50, 100 = the buyer pays all of it on top. Omit to keep the seller's normal behaviour (their account setting, or their agency's fee deal). An explicit value applies to this copy only, and also inside an agency fee deal. Any other value is a 400.One of 0, 50, 100
partner_fee_buyer_share_percent
integer
Who pays dropp's PARTNER fee (the seller-side commission) on sales through this copy, as the share (%) the BUYER bears on top of the price: 0 = the seller bears it all (the default, same as omitting the field, and stored as null), 50 = split 50/50. There is no 100. Independent of the customer dial. Accepted but without effect for a seller under an agency fee deal, where the partner fee is 0: see the effective values in the response. Any other value is a 400.One of 0, 50
operator_profile_id
string
dropp profile id of the team member this copy is for. Sales through it are credited to them in the agency chatter stats. Must be an ADMIN or MANAGER of an agency the creator belongs to. Omit to credit the creator, which is the previous behaviour.
metadata
object
Opaque JSON object carried by this copy and echoed back on link_copy.opened / order.paid and GET /v1/orders. Max 8 KB, 5 levels deep, 100 keys.
Creates a long-lived, link-bound Mini App URL that can be pasted anywhere on Telegram (DMs, groups, channels). Buyers are identified at checkout via Telegram-verified initData; the purchased content is delivered to the buyer's own DM, and order.paid exposes buyer.telegram plus this copy's metadata. 404 when the Vault is not enabled on the platform.
Path parameters
Field
Type
Description
idrequired
string
Link ID
Headers
Field
Type
Description
Idempotency-Key
string
Unique key so retries replay the original response instead of creating a duplicate. Strongly recommended for create endpoints.
Request body
Field
Type
Description
operator_profile_id
string
dropp profile id of the team member this copy is for. Sales through it are credited to them in the agency chatter stats. Must be an ADMIN or MANAGER of an agency the creator belongs to. Omit to credit the creator, which is the previous behaviour.
metadata
object
Opaque JSON object carried by this copy and echoed back on link_copy.opened / order.paid and GET /v1/orders. Max 8 KB, 5 levels deep, 100 keys.
Donation pages (tips, "boosts") that fans pay through. Sharing and attribution work exactly like payment links: every donation page has tracked copies, each with its own slug, checkout_url and metadata, echoed back on order.paid ("type": "donation") and GET /v1/orders. All amounts are integer cents.
Every donation page carries livemode, set from the key that created it. Scopes: donation_links:read for reads, donation_links:write for create, copy and deactivate. Keys minted before these scopes existed don't carry them — create a new key with them ticked.
One donation page per creator (and per mode)
A creator has one donation page, whether it was created in the dropp app or through the API. A second POST /v1/donation-links for the same creator returns 409 donation_link_exists, with the existing page in error.details.donation_link_id when your key can read it. Don't create a second page to vary the terms: mint a copy of the existing one.
JSON
{
"error": {
"code": "donation_link_exists",
"message": "This creator already has a donation page (don_lk_abc123). Only one is allowed per creator: mint tracked copies of it with POST /v1/donation-links/don_lk_abc123/copies.",
"status": 409,
"request_id": null,
"details": { "donation_link_id": "don_lk_abc123", "is_active": true }
}
}
details.is_active tells you what to do next. true: mint copies of that page. false: the page was deactivated (DELETE deactivates, it does not free the slot) and cannot take payments, so don't mint copies of it — their checkout would be refused. Reactivation is not available through the API yet; the creator can do it in the dropp app. When the existing page is outside your key's scope, the 409 carries no details.
A test key gets its own slot: a creator can have one live donation page and one test donation page at the same time, and each key only ever sees its own.
Per-copy minimum (min_amount_cents)
POST /v1/donation-links/{id}/copies accepts an optional min_amount_cents: the minimum a fan must give when checking out through that copy. It replaces the page minimum for that copy only, and may be lower or higher than it. Omit it to inherit the page minimum.
The response always carries the effective minimum as copy.min_amount_cents. It returns 400 when the value is below 100 or above the page's max_cents. The minimum belongs to the copy URL: a fan who opens the bare page gets the page minimum.
DELETE /v1/donation-links/{id} is a soft delete: the page stops accepting donations. It is idempotent; there is no hard delete, because orders reference the page. Every write endpoint accepts an Idempotency-Key.
The connected creator to scope this read to. REQUIRED for OAuth installs in multi-creator mode (else 400 ambiguous_scope); ignored by single-creator installs and API keys, which are already scoped.
is_active
boolean
Filter by active status
created_after
string
Filter donation links created on or after this date (ISO-8601)
created_before
string
Filter donation links created on or before this date (ISO-8601)
Atomically creates a donation page and mints a shareable tracked copy carrying opaque metadata. Returns the checkout URL. The metadata is echoed back on order.paid webhooks and GET /v1/orders. All amounts in minor units (cents). A creator has ONE donation page: a second create returns 409 donation_link_exists naming the existing page. If it is active (error.details.is_active), mint more copies of it with POST /v1/donation-links/{id}/copies; a deactivated page cannot take payments and cannot be reactivated through the API yet.
Headers
Field
Type
Description
Idempotency-Key
string
Unique key so retries replay the original response instead of creating a duplicate. Strongly recommended for create endpoints.
Request body
Field
Type
Description
customer_fee_buyer_share_percent
integer
Who pays dropp's CUSTOMER fee on sales through this copy, as the share (%) the BUYER bears: 0 = the seller absorbs it (the buyer pays the listed price), 50 = split 50/50, 100 = the buyer pays all of it on top. Omit to keep the seller's normal behaviour (their account setting, or their agency's fee deal). An explicit value applies to this copy only, and also inside an agency fee deal. Any other value is a 400.One of 0, 50, 100
partner_fee_buyer_share_percent
integer
Who pays dropp's PARTNER fee (the seller-side commission) on sales through this copy, as the share (%) the BUYER bears on top of the price: 0 = the seller bears it all (the default, same as omitting the field, and stored as null), 50 = split 50/50. There is no 100. Independent of the customer dial. Accepted but without effect for a seller under an agency fee deal, where the partner fee is 0: see the effective values in the response. Any other value is a 400.One of 0, 50
profile_id
string
Creator profile that will own the donation link. Defaults to the API key owner. When set to another profile, the key must be agency-scoped and the profile must be a CREATOR in that agency.
title
string
Public title of the donation page
description
string
Public description of the donation page
min_amount_cents
integer
Minimum donation in minor units (cents).
max_amount_cents
integer
Maximum donation in minor units (cents). Omit for no cap.
preset_amounts_cents
string[]
Suggested amounts shown to the donor, in minor units (cents).
currency_code
string
ISO 4217 currency code.
metadata
object
Opaque JSON object attached to the initial tracked copy and echoed back on order.paid webhooks and GET /v1/orders.
409Idempotency-Key conflict, or `donation_link_exists`: the creator already has a donation page (`error.details.donation_link_id`, `error.details.is_active`).
Creates a new shareable copy (own slug + metadata) of a donation link you own — attribute campaigns/chatters without recreating the page. Optional min_amount_cents sets a minimum for this copy only; the response always returns the effective minimum as copy.min_amount_cents.
Path parameters
Field
Type
Description
idrequired
string
Donation link ID
Headers
Field
Type
Description
Idempotency-Key
string
Unique key so retries replay the original response instead of creating a duplicate. Strongly recommended for create endpoints.
Request body
Field
Type
Description
customer_fee_buyer_share_percent
integer
Who pays dropp's CUSTOMER fee on sales through this copy, as the share (%) the BUYER bears: 0 = the seller absorbs it (the buyer pays the listed price), 50 = split 50/50, 100 = the buyer pays all of it on top. Omit to keep the seller's normal behaviour (their account setting, or their agency's fee deal). An explicit value applies to this copy only, and also inside an agency fee deal. Any other value is a 400.One of 0, 50, 100
partner_fee_buyer_share_percent
integer
Who pays dropp's PARTNER fee (the seller-side commission) on sales through this copy, as the share (%) the BUYER bears on top of the price: 0 = the seller bears it all (the default, same as omitting the field, and stored as null), 50 = split 50/50. There is no 100. Independent of the customer dial. Accepted but without effect for a seller under an agency fee deal, where the partner fee is 0: see the effective values in the response. Any other value is a 400.One of 0, 50
operator_profile_id
string
dropp profile id of the team member this copy is for. Sales through it are credited to them in the agency chatter stats. Must be an ADMIN or MANAGER of an agency the creator belongs to. Omit to credit the creator, which is the previous behaviour.
metadata
object
Opaque JSON object carried by this copy and echoed back on order.paid webhooks and GET /v1/orders. Max 8 KB, 5 levels deep, 100 keys.
min_amount_cents
integer
Minimum donation for a fan who checks out through THIS copy, in minor units (cents) of the donation link currency. Replaces the donation link's own minimum for this copy only; may be lower or higher than it. Must be an integer of at least 100 and no more than the donation link's max_cents. Omit to inherit the donation link's minimum.
Wishlists are shared and tracked the same way as links — plus items. Fans contribute toward each item; wishlist purchases arrive as orders with "type": "wishlist".
Scopes: wishlists:read for reads, wishlists:write for create, items, copy and archive. Older keys don't carry these scopes — create a new key with them ticked.
Creating.name is the only required field; send up to 50 items, created in the order you send them. The response includes the wishlist, its items and a first tracked copy. profile_id defaults to the key owner; agency keys may name their creators.
Items.funded_amount_cents is how much fans have contributed so far; is_funded flips when the target is reached. Items inherit the wishlist's currency. Item URLs (amazon_url, image_url) must include the protocol.
Copies.POST /v1/wishlists/{id}/copies takes the same body as the donation-link copy endpoint (metadata, operator_profile_id and the per-copy fee fields). An archived wishlist returns 404.
Archiving.DELETE /v1/wishlists/{id} is a soft delete: the wishlist stops accepting contributions. It is idempotent.
The connected creator to scope this read to. REQUIRED for OAuth installs in multi-creator mode (else 400 ambiguous_scope); ignored by single-creator installs and API keys, which are already scoped.
is_active
boolean
Filter by active status
created_after
string
Filter wishlists created on or after this date (ISO-8601)
created_before
string
Filter wishlists created on or before this date (ISO-8601)
Atomically creates a wishlist (optionally with items) and mints a shareable tracked copy carrying opaque metadata. Returns the checkout URL. The metadata is echoed back on order.paid webhooks and GET /v1/orders. All amounts in minor units (cents).
Headers
Field
Type
Description
Idempotency-Key
string
Unique key so retries replay the original response instead of creating a duplicate. Strongly recommended for create endpoints.
Request body
Field
Type
Description
profile_id
string
Creator profile that will own the wishlist. Defaults to the API key owner. When set to another profile, the key must be agency-scoped and the profile must be a CREATOR in that agency.
namerequired
string
Wishlist name
description
string
Wishlist description
currency_code
string
ISO 4217 currency code.
items
object[]
Items to create with the wishlist (in display order).
namerequired
string
Item name
target_price_centsrequired
integer
Target price in minor units (cents).
amazon_url
string
Product URL (e.g. Amazon)
image_url
string
Image URL for the item
metadata
object
Opaque JSON object attached to the initial tracked copy and echoed back on order.paid webhooks and GET /v1/orders.
Creates a new shareable copy (own slug + metadata) of a wishlist you own — attribute campaigns/chatters without recreating the wishlist.
Path parameters
Field
Type
Description
idrequired
string
Wishlist ID
Headers
Field
Type
Description
Idempotency-Key
string
Unique key so retries replay the original response instead of creating a duplicate. Strongly recommended for create endpoints.
Request body
Field
Type
Description
customer_fee_buyer_share_percent
integer
Who pays dropp's CUSTOMER fee on sales through this copy, as the share (%) the BUYER bears: 0 = the seller absorbs it (the buyer pays the listed price), 50 = split 50/50, 100 = the buyer pays all of it on top. Omit to keep the seller's normal behaviour (their account setting, or their agency's fee deal). An explicit value applies to this copy only, and also inside an agency fee deal. Any other value is a 400.One of 0, 50, 100
partner_fee_buyer_share_percent
integer
Who pays dropp's PARTNER fee (the seller-side commission) on sales through this copy, as the share (%) the BUYER bears on top of the price: 0 = the seller bears it all (the default, same as omitting the field, and stored as null), 50 = split 50/50. There is no 100. Independent of the customer dial. Accepted but without effect for a seller under an agency fee deal, where the partner fee is 0: see the effective values in the response. Any other value is a 400.One of 0, 50
operator_profile_id
string
dropp profile id of the team member this copy is for. Sales through it are credited to them in the agency chatter stats. Must be an ADMIN or MANAGER of an agency the creator belongs to. Omit to credit the creator, which is the previous behaviour.
metadata
object
Opaque JSON object carried by this copy and echoed back on order.paid webhooks and GET /v1/orders. Max 8 KB, 5 levels deep, 100 keys.
Orders are what fans paid for. Each order carries the metadata of the link copy it was bought through (see Tracking fans & attribution), or null if none.
type
Description
purchase
Regular link sale
donation
Boost (tip or donation)
wishlist
Wishlist item purchase
paid_chat
Paid chat session
The status filter takes the uppercase value (?status=PAID), while the status field in the response is lowercase (paid).
Every order carries livemode. A live key lists only real orders (livemode: true); a test key lists only sandbox orders paid with a test card (livemode: false). The two never mix, and an order id from one mode is a 404 on a key of the other.
amount.subtotal_cents is the listed price and amount.total_cents is what the buyer paid, which differ when the buyer bears part of dropp's fee.
The connected creator to scope this read to. REQUIRED for OAuth installs in multi-creator mode (else 400 ambiguous_scope); ignored by single-creator installs and API keys, which are already scoped.
status
string
Filter by order statusOne of CREATED, PAID, ERROR, REFUNDED, PENDING_REFUND
link_id
string
Filter by link ID
created_after
string
Filter orders created on or after this date (ISO-8601)
created_before
string
Filter orders created on or before this date (ISO-8601)
Transactions are revenue records: who received money, and from which order. seller is the creator who made the sale — distinct from recipient, who received the money. id is trx_… on current rows and inc_… on rows from before September 2025; both come from the same endpoint.
The type filter takes the uppercase enum — REVENUE, AGENCY_REFERRER, CREATOR_REFERRER. Anything else is a 400. The type field in the response is lowercase, and can also be income on a recent row that has no revenue type yet. Every row carries livemode: a live key only ever returns livemode: true rows, and a test key only livemode: false ones — sandbox charges never appear on a live key.
Creators inside an agency
Where a creator's earnings land depends on their revenue split. When the agency takes the cut, the earning is recorded against the agency, not the creator — so a creator-scoped call returns nothing for them even though they are selling. On dropp today that is most earnings, not an edge case.
If an agency admin connected that creator to your app, your token also reads what that agency earned from that creator's sales. Keep naming one creator per request:
recipient says who holds the money — { "type": "agency" } on these rows, { "type": "creator" } when the split pays the creator directly. A creator-facing UI should not present an agency-held amount as the creator's own balance.
seller ties the row back to the profile_id you asked for on REVENUE rows. On the referral types it does not: there the creator you asked for is the referrer, and seller is whoever they referred.
Filter with ?type=REVENUE for sale proceeds only; agency referral commissions also appear here.
The connected creator to scope this read to. REQUIRED for OAuth installs in multi-creator mode (else 400 ambiguous_scope); ignored by single-creator installs and API keys, which are already scoped.
type
string
Filter by transaction typeOne of REVENUE, AGENCY_REFERRER, CREATOR_REFERRER
created_after
string
Filter transactions created on or after this date (ISO-8601)
created_before
string
Filter transactions created on or before this date (ISO-8601)
Webhooks send real-time HTTP callbacks to your server when events happen on your dropp account: a paid order, an opened link, a tracked copy being viewed. Every delivery is a signed POST — see Security.
Managing endpoints
Register endpoints with your API key (webhooks:write; webhooks:read for the reads), or from Settings → API → Webhook Endpoints in the dashboard.
Max 5 endpoints per account. Each subscribes to 1–10 event types.
URLs must be HTTPS and resolve to a public IP.
The signing secret is returned only once, on create and on rotate. Rotation is an instant cutover — there is no overlap window with the old secret.
A failed delivery can be redelivered with POST /v1/webhooks/deliveries/{id}/retry (5 per minute).
App feed — one endpoint for every connected creator
If your app serves many creators, don't register an endpoint per creator (that is one signing secret each). Register a single app feed with your app key and no Bearer:
The feed carries the subscribed events for every creator your app holds an active connection for — provisioned or OAuth-connected. Each delivery's envelope carries profile_id, so route on that.
Revocation is immediate. The connection is re-checked on every event: a creator who disconnects your app stops appearing on the feed at once.
A creator's own endpoints still fire. If a creator also registered their own webhook, both receive the event with the same X-Dropp-Event-Id — deduplicate on it.
Delivery budget for a feed is 300/min (vs 30/min for a single creator's endpoint). Excess is delayed, never dropped.
Signature verification, retry schedule and circuit breaker are identical to a normal endpoint.
The connected creator to scope this read to. REQUIRED for OAuth installs in multi-creator mode (else 400 ambiguous_scope); ignored by single-creator installs and API keys, which are already scoped.
is_active
boolean
Filter by active status
agency_id
string
Filter by agency
Headers
Field
Type
Description
X-Dropp-App-Keyrequired
string
Your partner application key (drp_app_live_...). This endpoint authenticates the APP itself — send NO Authorization Bearer.
One endpoint that receives the subscribed events for EVERY creator your app is connected to — provisioned or OAuth-connected. The signing secret is returned once, here.
Each delivery carries profile_id in the envelope, so you know whose event it is. A creator who revokes your app stops appearing on the feed immediately — the connection is re-checked per event, not cached.
Headers
Field
Type
Description
X-Dropp-App-Keyrequired
string
Your partner application key (drp_app_live_...). This endpoint authenticates the APP itself — send NO Authorization Bearer.
The connected creator to scope this read to. REQUIRED for OAuth installs in multi-creator mode (else 400 ambiguous_scope); ignored by single-creator installs and API keys, which are already scoped.
Event type to simulate. Defaults to the first event the endpoint subscribes to.One of payment.received, payment.refunded, order.created, order.paid, order.upsell_paid, order.refunded, link.created, link.updated, link.opened, link_copy.opened
The connected creator to scope this read to. REQUIRED for OAuth installs in multi-creator mode (else 400 ambiguous_scope); ignored by single-creator installs and API keys, which are already scoped.
endpoint_id
string
Filter by endpoint ID
status
string
Filter by delivery statusOne of pending, delivered, failed, retrying
event_type
string
Filter by event type
created_after
string
Filter deliveries created on or after this date (ISO-8601)
created_before
string
Filter deliveries created on or before this date (ISO-8601)
A shared link copy has been opened (includes its metadata)
Live
order.created
A new order has been created
Not yet emitted
order.refunded
An order has been refunded
Not yet emitted
payment.received
A payment has been successfully received
Not yet emitted
payment.refunded
A payment has been refunded
Not yet emitted
link.created
A new payment link has been created
Not yet emitted
link.updated
A payment link has been updated
Not yet emitted
Not yet emitted events can already be subscribed to and appear in test deliveries, but no real deliveries are sent for them today — don't build a flow that depends on them yet. Read payment state from order.paid and order.upsell_paid.
Coverage notes for order.paid: an upsell or downsell child order emits order.upsell_paid instead; auction wins and subscription renewals don't currently emit order.paid. Test-mode orders (sandbox purchases made through a test key's links) emit the same events, but only to endpoints registered with a test key, with livemode: false; a live endpoint never receives a test sale.
The envelope
Every delivery carries these headers:
Header
Description
X-Dropp-Event-Type
Event type (e.g. order.paid)
X-Dropp-Event-Id
Unique event ID — deduplicate on it
X-Dropp-Timestamp
Unix timestamp (seconds) when the event was signed
X-Dropp-Signature
HMAC-SHA256 signature (sha256=…)
User-Agent
Dropp-Webhooks/1.0
Every payload shares one envelope: event, occurred_at (ISO-8601, stamped at emission), object, data, plus profile_id (the creator the event belongs to), agency_id (null on a creator's own endpoint, the agency id when the delivery goes to an agency endpoint) and livemode (true on an endpoint registered with a live key, false on one registered with a test key). The event id travels only in the X-Dropp-Event-Id header, not in the body.
If a creator belongs to an agency and both have subscribed endpoints, each receives its own delivery of the same event — same X-Dropp-Event-Id, different agency_id.
Differences from order.paid: amount has no subtotal_cents; there is nometadata field (link-copy attribution belongs to the parent order); client_metadata is inherited from the parent order; step_metadata is the per-step object set on the funnel step.
Fires when a fan opens a shared copy of a link. Unlike link.opened, it carries the copy slug and the metadata you attached — use it to know which fan or conversation opened the link.
payment.received and payment.refunded (object transaction) and link.created and link.updated (object link) are subscribable and testable but not emitted in production yet. Their shapes when live:
Every webhook is signed with your endpoint's secret. Always verify signatures so you only act on payloads dropp actually sent, and reject events whose timestamp is older than 5 minutes to prevent replays.
The signature is computed over the timestamp header and the raw request body:
const crypto = require('crypto');
function verifyWebhook(rawBody, headers, secret) {
const timestamp = headers['x-dropp-timestamp'];
const signature = headers['x-dropp-signature'];
// Reject stale events (replay protection)
if (Math.floor(Date.now() / 1000) - parseInt(timestamp) > 300) {
throw new Error('Event too old');
}
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
throw new Error('Invalid signature');
}
return JSON.parse(rawBody);
}
Python
Python
import hmac, hashlib, json, time
def verify_webhook(raw_body: bytes, headers: dict, secret: str):
timestamp = headers["x-dropp-timestamp"]
signature = headers["x-dropp-signature"]
if int(time.time()) - int(timestamp) > 300:
raise ValueError("Event too old")
message = f"{timestamp}.{raw_body.decode()}"
expected = "sha256=" + hmac.new(
secret.encode(), message.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise ValueError("Invalid signature")
return json.loads(raw_body)
Retries & Delivery
Retry schedule
Failed deliveries are retried with exponential backoff:
Attempt
Delay
1
Immediate
2
10 seconds
3
60 seconds
4
10 minutes
5
1 hour
Delivery guarantees
Each attempt times out after 10 s; any 2xx response counts as delivered. Anything else is a failure and enters the retry schedule.
Ordering is not guaranteed — events can arrive out of order.
Delivery is at-least-once: deduplicate on X-Dropp-Event-Id. For order.paid and order.upsell_paid the id is deterministic (order.paid:<order_id> / order.upsell_paid:<upsell_order_id>), and a reconciliation sweep re-sends paid orders that missed delivery — so these two events can arrive late (up to ~26 h after payment) or occasionally more than once.
Outbound traffic is capped at 30 deliveries per minute per endpoint (300 for an app feed); excess is delayed, never dropped.
Circuit breaker
If an endpoint accumulates 10 consecutive failures, it is automatically deactivated. It reactivates after a 1-hour cooldown, or immediately when you reactivate it with PATCH /v1/webhooks/endpoints/{id}.
Testing webhooks
End to end, without real money: register an endpoint with a test key and pay one of that key's links with a test card. You receive the real event shape with livemode: false.
From the dashboard: go to Settings → API → Webhook Endpoints, open the ⋮ menu on an endpoint, and click Send Test. You'll see a success or failure notification with the response time.
Without a server: open webhook.site to get a unique URL, create an endpoint pointing to it, and send a test — the payload, headers and signature appear there instantly.
Scopes
Each API key is restricted to the scopes you tick when creating it. OAuth apps get the scope set they were registered with. Every endpoint in this reference names the scope it needs.
Scope
Access
(none)
GET /v1/me and GET /v1/creators — introspect the credential and list the creators it can act for
links:read
List and retrieve payment links and link folders
links:write
Create payment links and mint metadata-carrying shareable copies
donation_links:read
List and retrieve donation links
donation_links:write
Create donation links, mint tracked copies, deactivate
wishlists:read
List and retrieve wishlists and their items
wishlists:write
Create wishlists, add items, mint tracked copies, archive
orders:read
List and retrieve orders
transactions:read
List and retrieve transactions
content:read
List content and content folders; get processing status and signed download URLs
content:write
Create content upload URLs and archive content
webhooks:read
List webhook endpoints, deliveries and event types
webhooks:write
Create, update and delete webhook endpoints; send test events; retry deliveries
creators:write
Add a creator to your agency and invite them (POST /v1/agency/creators) — agency keys only, never granted to OAuth apps
creators:provision
App-level scope, not a key scope. Lets an approved affiliate partner create referred creator or agency accounts (POST /v1/creators) with X-Dropp-App-Key
Branch on error.code, not on message — messages are for humans and may change. Some errors add a details object (for example donation_link_exists). Include request_id when you contact us about a failed call.
Code
Status
Description
bad_request
400
Invalid parameters or body
ambiguous_scope
400
Multi-creator OAuth install, and the request named no creator. Send profile_id
operator_not_in_scope
400
operator_profile_id is not an ADMIN or MANAGER of an agency the creator belongs to
unauthorized
401
Missing, expired or invalid credential — or the creator disconnected your app
forbidden
403
Insufficient scopes, or IP not allowed
unsupported_credential
403
This credential type cannot perform the operation (e.g. an OAuth token calling POST /v1/agency/creators)
test_mode_unsupported
403
A test key called an endpoint that has no test mode (wishlists, POST /v1/agency/creators). Use a live key
profile_not_connected
403
The profile_id is not in your active grant set — also what an agency id or a creator who revoked you returns
no_active_grant
403
The OAuth install has no callable creator. The creator must connect themselves
grant_limit_reached
403
Your install reached its ceiling of connected creators
not_found
404
Resource not found
conflict
409
The request conflicts with the current state — e.g. an Idempotency-Key reused while the first request is still in flight
email_unavailable
409
POST /v1/agency/creators: this email cannot be onboarded through the API — usually it already has a dropp account. Terminal for that address
donation_link_exists
409
The creator already has a donation page. Mint copies of it instead
rate_limited
429
Too many requests from this caller — back off for this creator only (Retry-After, seconds)
app_rate_limited
429
Your app's aggregate ceiling — throttle the whole integration
internal_error
500
Unexpected server error
provisioning_failed
502
POST /v1/agency/creators: account creation was rolled back after a downstream failure. Safe to retry with the same Idempotency-Key
service_unavailable
503
A dependency is temporarily unavailable (e.g. the rate limiter). Retry with backoff
invites_unavailable
503
POST /v1/agency/creators: invite delivery is down, so no account was created — retry later