For developers

The drop.top API

Everything the console does, automatable: create channels, compose and send alerts, and read the honest half of what happened to them. There is no paid tier for access to your own account — API access is a channel-plan feature, not a separate product.

Authentication

A single bearer token, created in the console under API & webhooks:

Authorization: Bearer sk_live_...

Only a hash of the key is stored, so it is shown once and cannot be recovered. Keys cannot create other keys — that requires a signed-in browser session, so a leaked key cannot mint replacements for itself. Every key carries a list of scopes, chosen when it is minted; each operation below names the one it requires.

Everything here is also available to an AI assistant over MCP — one tool per operation, same handlers, same rules, no key pasted anywhere. Connect an assistant.

Errors: one envelope

Every failure, from every route, in one shape. error.param names the offending field when there is one — the difference between an integration taking ten minutes and taking an afternoon.

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="alerts:send"
Content-Type: application/json

{
  "error": {
    "type": "insufficient_scope",
    "message": "This credential lacks alerts:send. Mint a key carrying it, or reconnect the assistant approving it.",
    "param": null
  }
}
error.typeHTTPMeaning
invalid_request 422

Validation failed. param names the offending field when there is one.

unauthorized 401

No credential, or a revoked/expired one.

payment_required 402

Reserved on this surface until plans land — read it as "not yet, and here is the thing you can do about it", never as a bug.

insufficient_scope 403

Your credential was minted without this permission — mint a key with it, or reconnect the assistant approving it. Comes with a WWW-Authenticate header naming the scope.

forbidden 403

Not your role or your data. A different word from insufficient_scope on purpose: a better credential will not help.

not_found 404

No such row — or not yours, which answers identically.

conflict 409

The state refuses the verb: a taken handle, a paused channel asked to send, a sent alert asked to change. The message names the way out.

rate_limited 429

Over a ceiling. The message names it and when to retry.

server_error 500

Ours. Retry with backoff.

The change policy for 1.x is additive only: fields, endpoints, webhook events and enum values are added; nothing documented is renamed, removed or retyped, and error.type strings never change meaning. Ignore fields you do not recognise and parse enums tolerantly.

Rate limits

Two ceilings exist today, both stated where they bite:

WhatLimitWhy this shape
POST /v1/pushers 10 / minute / creator A handle is a claim on a name in a shared namespace — creating them in a loop is squatting, not usage.
Alert sends 30 / minute / pusher A courtesy to the people receiving them, not to our database — and per pusher, so one busy channel cannot quiet another belonging to the same creator.

Drafting is never rate-limited: a creator drafting twenty alerts for tomorrow's festival is doing their job. The limits fail open during a cache outage on purpose — the caller is authenticated, and refusing real work to protect a counter would be backwards. Per-plan limits arrive with billing.

Sending an alert

Three calls, and the split is deliberate: composing and sending are separate scopes, so the script that writes an alert can be forbidden from publishing it.

# 1. compose it as a DRAFT. Nothing reaches a phone yet.
#    Needs alerts:write.
curl -X POST https://drop.top/api/v1/pushers/$PUSHER_ID/alerts \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Bus 34 is not running today",
       "body":"Walk, or take the 12 from the same stop.",
       "priority":"normal",
       "send":false}'

# 2. read it back and look at it. This is the step people skip.
curl https://drop.top/api/v1/pushers/$PUSHER_ID/alerts/$ALERT_ID \
  -H "Authorization: Bearer $KEY"

# 3. send it. THIS IS THE IRREVERSIBLE ONE — it needs alerts:send,
#    and cancelling afterwards only stops the phones that have not
#    fetched it yet.
curl -X PATCH https://drop.top/api/v1/pushers/$PUSHER_ID/alerts/$ALERT_ID \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"action":"send"}'

An alert cannot be unsent. action: cancel stops it reaching phones that have not fetched it yet and does nothing at all to the ones that already have it. Compose with send: false unless you mean it. To schedule instead, pass send_at: the alert becomes visible when the inbox query starts including it, which is what lets a server that was down deliver late rather than never.

priority is a ceiling and never an outcome. Whether it becomes a sound, a banner or a silent line is decided on the handset, by what that subscriber granted this channel. Nothing in this API can promise an alert will ring.

Webhooks — the signed events, the verification snippet and the honest list of what never fires — moved to their own page.

Reference

Generated from the OpenAPI document (v2.0.0). Samples print https://drop.top; the API answers on whatever origin serves this page, under /api — swap the host if yours differs. Path segments in braces ({id}) are yours to fill.

Account

GET /api/v1/me

Your creator profile

account:read MCP: get_account

Responses

200

OK

curl

curl https://drop.top/api/v1/me \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/me', {
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
const data = await res.json();

Webhooks

GET /api/v1/webhooks

List endpoints

webhooks:read MCP: list_webhooks

Responses

200

OK

curl

curl https://drop.top/api/v1/webhooks \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/webhooks', {
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
const data = await res.json();
POST /api/v1/webhooks

Register an endpoint

webhooks:write MCP: create_webhook

Request body

FieldTypeDescription
url * string (uri)
events * alert.sent | alert.scheduled | alert.cancelled | subscriber.created | subscriber.deleted[]

What each event means — and, as important, what is deliberately absent:

  • alert.sent fires when an alert becomes SENT: an immediate send, a published draft, or a scheduled alert being sent early by hand.
  • alert.scheduled fires when the alert is scheduled, not at its send_at instant. Nothing runs at that minute by design — the alert becomes visible because the inbox query's send_at <= now() starts including it, which is the property that lets a server that was down deliver late rather than never. An event AT the instant is therefore impossible, and this is the honest substitute. If you need something to happen at send_at, schedule it yourself from this event's payload.
  • alert.cancelled fires on withdrawal, with the usual caveat: phones that already received the alert keep it.
  • subscriber.created / subscriber.deleted carry the pusher and a count, never anything device-shaped.
  • There is no alert.delivered and no alert.failed, and there will not be: no per-device delivery row exists anywhere (a phone's inbox is a query), so an event claiming a delivery would be fiction.

Responses

201

Created. secret is shown once — store it.

curl

curl -X POST https://drop.top/api/v1/webhooks \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com","events":["alert.sent"]}'

fetch

const res = await fetch('https://drop.top/api/v1/webhooks', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer sk_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({"url":"https://example.com","events":["alert.sent"]}),
});
const data = await res.json();
DELETE /api/v1/webhooks/{webhook_id}

Remove an endpoint

webhooks:write MCP: delete_webhook

Not in the original draft of this document, and added because its absence was a real gap: a creator whose endpoint moved had no way to stop deliveries to the old one.

Parameters

NameInType
webhook_id * path string

Responses

204

Removed

404

Not found

curl

curl -X DELETE https://drop.top/api/v1/webhooks/{webhook_id} \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/webhooks/{webhook_id}', {
  method: 'DELETE',
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
if (!res.ok) throw new Error(await res.text());

Pushers

Alert channels. A channel owns a permanent public handle, a category, an optional geofence, and the list of capabilities it asks subscribers for. Creating one is claiming a name in a shared namespace, which is why creation is rate-limited and deletion refuses while anybody still subscribes.

GET /api/v1/pushers

List your channels

channels:read MCP: list_pushers

Every pusher you may see — for a workspace member restricted to certain channels, that restriction is folded in here (an editor limited to two channels lists two channels, and the difference is scope, not an error; the same applies to a channel-bound key). Each row carries drafts and scheduled, the unsent backlog the studio prints as "2 drafts" against a channel card.

Responses

200

OK

curl

curl https://drop.top/api/v1/pushers \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/pushers', {
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
const data = await res.json();
POST /api/v1/pushers

Create a channel

channels:write MCP: create_pusher

Creating a channel claims a permanent public handle at drop.top/<handle> — permanent because it gets printed on posters and encoded in QR codes, which is also why it can never be edited afterwards (see PATCH). The unique index is the authority on whether a handle is free: two requests for the same one a millisecond apart both get an answer, and the loser's answer is the 409 below.

Rate-limited to 10 per minute per creator — a handle is a claim on a name in a shared namespace, so creating them in a loop is squatting rather than usage.

Request body

FieldTypeDescription
handle * string

drop.top/<handle>. Lowercase letters, digits and single dashes; lowercased for you if you forget. No leading or trailing dash and no double dash (xn-- is the punycode prefix, and a handle that looks like an internationalised domain in a printed QR code is a phishing primitive). Route-shaped and official-looking words (admin, api, verified, …) are reserved and refused.

name * string
category * cities | travel | events | concerts | restaurants | hotels | business | ai | sports | emergency | community
description * string

Required on create — it is the one line shown in search results.

long string | null
site string | null

https only, and this is the one place refusing cleartext costs a caller something real. It is refused anyway: this URL is opened from a notification a reader trusted, on a phone that may be on a café network — and the app's release build would refuse to load http regardless, so accepting it would store a link that can only fail later.

city string | null
lat number | null

The geofence is all-or-nothing: lat, lon and radius_km together, or all null. A latitude with no radius describes no circle and would silently deliver nothing to a location-filtered subscriber — which presents as a channel that does not work rather than as a configuration mistake, so the half-filled form is a 422.

lon number | null
radius_km number | null

Kilometres, decimal. Small circles are legitimate — see the same field on an alert for what a handset's own accuracy does to one.

loc_rule string | null
requested_perms notify | sound | vibrate | tts | full | loc | quiet[]

notify is added if you forget it — a channel that never asks for notify has asked for nothing at all. Unknown keys are dropped rather than rejected: an unknown key is a client from a newer build, and losing one permission is a better failure than losing the write.

alert_types string[]
tags string[]

Lowercased, deduplicated. Searched alongside the name.

is_public boolean

false seals the channel: only a phone holding an invite code may subscribe or read what it has sent. Creating a channel private, or switching an open one to private, mints a first invite link and returns it as invite — a private channel with no way in would be a channel nobody, including its owner, can ever join. Existing subscribers are never removed by the switch.

The default is true, and omitting the field creates a channel anyone can find and join. That default is kept for compatibility with scripts written against it; the MCP create_pusher tool makes the field REQUIRED instead, because a model omitting a parameter is not the same act as an operator accepting a documented default.

image_url string | null

https only.

gradient_from string

Fallback-avatar gradient. An invalid colour keeps the current (or default) one.

gradient_to string
initials string

Derived from the name when not supplied, so unbranded channels are not a column of identical grey circles.

Responses

201

Created

409

The handle is taken.

422

Validation failed

429

Over a ceiling. The message names it and when to retry. These limits fail open during a cache outage on purpose — the caller is authenticated and refusing real work to protect a counter would be backwards.

curl

curl -X POST https://drop.top/api/v1/pushers \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"handle":"<handle>","name":"<name>","category":"cities","description":"<description>"}'

fetch

const res = await fetch('https://drop.top/api/v1/pushers', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer sk_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({"handle":"<handle>","name":"<name>","category":"cities","description":"<description>"}),
});
const data = await res.json();
GET /api/v1/pushers/{id}

Fetch one channel

channels:read MCP: get_pusher

The pusher plus the two numbers the studio header shows: subscribers (recomputed from the subscription table on this single-row read, because the denormalised counter is the thing most likely to have drifted) and alerts_sent, the all-time count of SENT alerts.

Parameters

NameInType
id * path string

Responses

200

OK

404

Not found

curl

curl https://drop.top/api/v1/pushers/{id} \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}', {
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
const data = await res.json();
PATCH /api/v1/pushers/{id}

Update a channel

channels:write MCP: update_pusher

A PATCH in the real sense: a field that is absent is left alone; a field that is present and null is cleared. Two rules with reasons:

The handle is not editable, and that is a product decision rather than a missing feature. It is printed on posters, encoded in QR codes and typed by people; a rename breaks every one of those with no redirect possible. Sending a different handle is a 422; a creator who needs a new one creates a new pusher and tells their subscribers, which is the honest version of what a rename would be anyway.

status takes only active and paused — the creator's own pause switch. suspended is staff's: accepting it here would let a creator suspend themselves into a state only an admin can undo, and a suspended channel answers 403 to any attempt to flip its status.

Parameters

NameInType
id * path string

Request body

FieldTypeDescription
name string
category cities | travel | events | concerts | restaurants | hotels | business | ai | sports | emergency | community
description string

Required on create — it is the one line shown in search results.

long string | null
site string | null

https only, and this is the one place refusing cleartext costs a caller something real. It is refused anyway: this URL is opened from a notification a reader trusted, on a phone that may be on a café network — and the app's release build would refuse to load http regardless, so accepting it would store a link that can only fail later.

city string | null
lat number | null

The geofence is all-or-nothing: lat, lon and radius_km together, or all null. A latitude with no radius describes no circle and would silently deliver nothing to a location-filtered subscriber — which presents as a channel that does not work rather than as a configuration mistake, so the half-filled form is a 422.

lon number | null
radius_km number | null

Kilometres, decimal. Small circles are legitimate — see the same field on an alert for what a handset's own accuracy does to one.

loc_rule string | null
requested_perms notify | sound | vibrate | tts | full | loc | quiet[]

notify is added if you forget it — a channel that never asks for notify has asked for nothing at all. Unknown keys are dropped rather than rejected: an unknown key is a client from a newer build, and losing one permission is a better failure than losing the write.

alert_types string[]
tags string[]

Lowercased, deduplicated. Searched alongside the name.

is_public boolean

false seals the channel: only a phone holding an invite code may subscribe or read what it has sent. Creating a channel private, or switching an open one to private, mints a first invite link and returns it as invite — a private channel with no way in would be a channel nobody, including its owner, can ever join. Existing subscribers are never removed by the switch.

The default is true, and omitting the field creates a channel anyone can find and join. That default is kept for compatibility with scripts written against it; the MCP create_pusher tool makes the field REQUIRED instead, because a model omitting a parameter is not the same act as an operator accepting a documented default.

image_url string | null

https only.

gradient_from string

Fallback-avatar gradient. An invalid colour keeps the current (or default) one.

gradient_to string
initials string

Derived from the name when not supplied, so unbranded channels are not a column of identical grey circles.

status active | paused

Pausing keeps the handle (nobody can impersonate a paused channel) and refuses sends until resumed.

Responses

200

OK

403

The channel is suspended — its status is staff's to change, not yours. error.type is forbidden; contact support.

404

Not found

422

Validation failed

curl

curl -X PATCH https://drop.top/api/v1/pushers/{id} \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name":"<name>"}'

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}', {
  method: 'PATCH',
  headers: { 'Authorization': 'Bearer sk_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({"name":"<name>"}),
});
const data = await res.json();
DELETE /api/v1/pushers/{id}

Delete a channel

channels:write MCP: delete_pusher

Deleting takes the subscribers and the alert history with it, and it also frees the handle — which is the part that cannot be undone. Somebody else may claim it the moment this returns, and every printed QR code then points at them. So a channel with subscribers refuses (the 409 below), and the remedy offered is the one that is actually reversible: pause it. A handle held by a paused channel is a handle nobody can impersonate.

Parameters

NameInType
id * path string

Responses

204

Deleted, handle freed.

404

Not found

409

The channel still has subscribers. Pause it instead.

curl

curl -X DELETE https://drop.top/api/v1/pushers/{id} \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}', {
  method: 'DELETE',
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
if (!res.ok) throw new Error(await res.text());
GET /api/v1/pushers/{id}/invites

The ways in to a private channel

channels:read MCP: list_invites

Every invite on this channel, newest first, codes included — this surface is already authenticated as the channel's own creator, and a link the owner cannot read back is a link they cannot print next month.

A private channel answers nobody who cannot present one of these. A public channel may hold invites and they do nothing; anyone can subscribe to it.

Parameters

NameInType
id * path string

Responses

200

OK

404

Not found

curl

curl https://drop.top/api/v1/pushers/{id}/invites \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}/invites', {
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
const data = await res.json();
POST /api/v1/pushers/{id}/invites

Mint an invite link

channels:write MCP: create_invite

Hand out one per audience — the poster at reception, the letter to one class, the on-call runbook. That is what makes it possible later to kill exactly the link that leaked without locking out everybody else; one code per channel makes that question unanswerable.

The code is drawn from the CSPRNG and is never derived from the handle: a code anybody can compute from the visible half of a link is decoration. url is the whole string a QR code should encode.

Parameters

NameInType
id * path string

Request body

FieldTypeDescription
label string | null

Where this link went, in your words. Never shown to a subscriber.

max_uses integer | null

How many phones may join with it. Null for unlimited; a one-shot link is 1.

expires_at string | null

When it stops working. Null for never. A time in the past is a 422 rather than a row: it would be a link that has never worked and cannot be told apart, in the list, from one that worked yesterday.

Responses

201

Created

404

Not found

422

Validation failed

curl

curl -X POST https://drop.top/api/v1/pushers/{id}/invites \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"label":"<label>"}'

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}/invites', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer sk_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({"label":"<label>"}),
});
const data = await res.json();
DELETE /api/v1/pushers/{id}/invites/{inviteId}

Revoke an invite link

channels:write MCP: revoke_invite

Revoking and evicting are two different acts and you must say which. By itself this stops the link admitting anybody else and costs nothing: every phone that already joined with it keeps the channel, which is what you want when a poster is replaced.

?remove_subscribers=true also unsubscribes exactly the devices that came in through this link — never the ones that predate invites, never another code's. That is destructive and silent: those people simply stop receiving the channel, with no message.

The row is not deleted. revoked_at is a timestamp, so the subscriptions it admitted still point at something and "when did we cut this off" has an answer. Revoking twice keeps the first timestamp.

Parameters

NameInTypeDescription
id * path string
inviteId * path string
remove_subscribers query true

Also unsubscribe the devices that joined with this link.

Responses

200

Revoked

404

Not found

curl

curl -X DELETE https://drop.top/api/v1/pushers/{id}/invites/{inviteId} \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}/invites/{inviteId}', {
  method: 'DELETE',
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
if (!res.ok) throw new Error(await res.text());

Alerts

What a channel says. One POST covers draft, schedule and send-now — the send flag and send_at decide which — and everything after SENT is deliberately narrow: a sent alert is a record, editable never, cancellable honestly ("stops reaching phones that have not polled; the rest keep it").

GET /api/v1/pushers/{id}/alerts

List a channel's alerts

alerts:read MCP: list_alerts

The history, newest first, with keyset paging on created_at rather than an offset — the history of a busy channel is appended to constantly, and OFFSET 50 in that situation skips rows that moved rather than showing the next page. Pass meta.next_before back as before for the next page; null means you have it all.

Parameters

NameInTypeDescription
id * path string
status query draft | scheduled | sent | cancelled
limit query integer
before query string (date-time)

An ISO 8601 instant — rows created strictly before it.

Responses

200

OK

404

Not found

422

Validation failed

curl

curl https://drop.top/api/v1/pushers/{id}/alerts \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}/alerts', {
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
const data = await res.json();
POST /api/v1/pushers/{id}/alerts

Compose an alert — draft, schedule or send

alerts:write MCP: create_alert

One POST, three outcomes, because they are one action with one validation:

bodyresult
send: false (or absent)draft — saved, invisible
send: true, future send_atscheduled — visible from that instant, no scheduler involved
send: true, no send_atsent — visible now

Composing needs alerts:write — that is this operation's declared scope. send: true additionally requires alerts:send, checked against the body: it is the authority to ring phones, which composing is not. Without it the refusal is the standard 403 insufficient_scope, WWW-Authenticate header and all.

Sending is one UPDATE, not a fan-out — there is no queue and no worker, and a scheduled alert needs nothing to run at the appointed minute (the inbox query starts including it). Publishing costs the same at four subscribers and four million.

The response is the created alert plus two report fields worth reading: blocked, what the HTML sanitiser removed (not an error — the alert was accepted — but markup that silently loses half its content is a support ticket), and notes, the capabilities this alert reaches for that its channel never asked subscribers for. A siren on a channel that never requested sound is simply silent — nothing fails, so the sentence in notes is the only way the author finds out. The fix is almost always to tick one more box on the channel.

Sends are rate-limited to 30 per minute per pusher — a courtesy to the people receiving them, not to our database, and per pusher so one busy channel cannot quiet another belonging to the same creator. Drafting is never rate-limited: a creator drafting twenty alerts for tomorrow's festival is doing their job.

Parameters

NameInType
id * path string

Request body

FieldTypeDescription
send boolean

The irreversible bit. Requires alerts:send. With a future send_at this schedules; without one it publishes immediately; false saves a draft.

title * string
body string | null
priority normal | important | urgent

A ceiling, not a command — and it defaults to the quiet one, which is the only reading under which a caller that never mentions loudness cannot accidentally be loud. An explicitly wrong value is still a 422; there is no guessing, only an absence with an obvious meaning.

layout text | image | html | document
image_url string | null

https only. Satisfies layout: image on its own, as does image_asset_id; one of the two is required for that layout.

image_asset_id string | null

A picture UPLOADED to this service rather than linked. The alert then travels with image_url pointing at our own media route, so nothing on the phone distinguishes the two — and this field wins where both are set.

THE ID IS MINTED IN THE STUDIO, NOT HERE. Uploading is a browser problem — a file on somebody's desktop — and the three calls that do it are session-authenticated and deliberately outside this contract, for the same reason cover_url exists and a cover upload endpoint does not. A script already has a URL for its picture and should send image_url. This field is documented because the studio composes through this endpoint and you will see it on the wire.

Sending null detaches an uploaded picture from a draft.

audio_asset_id string | null

The same arrangement for the alert's sound file: an upload we hold, surfaced to the phone as audio_url. Wins over audio_url where both are set, and null detaches it.

html_asset_id string | null

An uploaded .html file, kept as the author wrote it. IT IS NOT WHAT PHONES RENDER: the file's text is put through exactly the sanitiser (or, for layout: document, the sandbox) that html below goes through, and the result is what is stored and sent. The file itself is a record you can download back, served as text/plain and as an attachment — never as markup, from any origin.

Send it INSTEAD of html and the file's contents become the body. Send BOTH and html wins — the file is then only a record of where the body came from. That order exists because the studio uploads a file, shows its text in an editable box, and sends the box: if the file won, every edit made after uploading would vanish at the moment of sending. Null detaches.

html string | null

Required when layout is html or document. For html it is sanitised (at most 60,000 characters) and the removals come back in blocked; for document the page is kept whole up to four times that, rendered in the phone's sandboxed iframe where scripts are inert. POST /v1/drop/preview runs the identical code without storing anything.

actions AlertAction[]
requires_ack boolean

Puts the acknowledgement button on the card. Combined with expires_at, the window must stay open at least 5 minutes — the reader may be asleep, and an acknowledgement window shorter than the time it takes to pick up a phone produces a dashboard that says nobody responded to an evacuation instruction.

send_at string | null

With send: true, a future instant schedules; the alert becomes visible at that moment with no scheduler involved, so a server that was down at the minute delivers late rather than never.

expires_at string | null

"Stop treating this as current", not "pretend it never happened" — phones move it out of the spotlight and stop ringing, but a reader can still scroll back to last night's storm warning. Must be after send_at.

sound default | chime | ping | bell | knock | alarm | siren | silent | null

A tone id, not a file — the phone synthesises it, so it costs no asset, no decode latency, and no network at the one moment the phone may have none. default and anything unrecognised both store null, meaning "whatever this priority already sounds like": a retired tone id loses the tone, never the alert. silent is a choice, not an absence. Heard only if the channel asked for sound AND the subscriber granted it — otherwise the alert arrives quietly and the create response's notes says so.

audio_url string | null

An absolute https URL to an mp3 — or any audio the handset can decode — STREAMED at the moment the alert arrives. A jingle, the siren a city already uses, a recorded sentence in a voice the reader knows.

IT WINS OVER sound, AND sound IS ITS FALLBACK. The phone gives the fetch a few seconds; if it is slow, refused, unplayable or the phone has no network, the tone id plays instead. So attaching a file can never be the reason an alert was silent — which is the only arrangement under which streaming audio belongs in an emergency path at all.

Not proxied, not cached and not transcoded: the URL travels to the phone exactly as written and the phone fetches it from wherever you put it, so it must be publicly reachable with no login. A phone plays at most 20 seconds. Heard only where the channel asked for sound AND the subscriber granted it — the create response's notes says so when it did not.

vibrate default | none | short | double | long | sos | heartbeat | null

Same rules as sound: default and unknowns store null ("what this priority already does"); none stores, because "do not buzz" is a choice and not an absence.

tts_text string | null

What a phone reads aloud, where the channel asked for tts and the subscriber granted it. 400 characters because that is what the phone's speech call clamps to — storing more would store a sentence cut off at the moment it is spoken.

tts_lang string | null

BCP-47, e.g. "nl-BE". Null falls back to the phone's own language.

lat number | null

An alert's own circle — all three of lat/lon/radius_km or none, exactly like the channel's. It may NARROW the channel's area and never widen it: the subscribe screen already told every subscriber where this channel operates, and an alert reaching past that would make the one disclosure they read a false one. A channel with no circle of its own may target anywhere.

lon number | null
radius_km number | null

Kilometres, decimal — 0.05 is a 50-metre circle around one platform or one gate, and that is a supported thing to want. Containment against the channel area allows 2% of the CHANNEL radius, at least 10 m: enough that a circle poking a few metres over a hand-drawn boundary is accepted, not enough to double the area. Note what the number cannot buy — a handset locates itself to within roughly 5-20 m outdoors and tens of metres indoors, so a circle smaller than that alerts some people just outside it and misses some just inside it.

geo_rule string | null
collapse_key string | null

An author label — "storm-14", "gate-change" — that makes a newer alert replace an older one in the notification shade instead of piling up. A slug on purpose: the value is hashed into a notification id on two sides of a language boundary, and restricting the alphabet is what stops a key that round-trips differently producing two entries where the author expected one.

Responses

201

Created — as a draft, scheduled, or already sent; read status.

403

Either insufficient_scope (send: true without alerts:send — the WWW-Authenticate header is present and names the scope) or forbidden (the pusher is suspended and cannot send).

404

Not found

409

The pusher is paused. A creator who paused a channel and then sent an alert has contradicted themselves, and the resolution they want is this message — not an alert that goes nowhere. Resume it, or save the alert as a draft.

422

Validation failed

429

Over a ceiling. The message names it and when to retry. These limits fail open during a cache outage on purpose — the caller is authenticated and refusing real work to protect a counter would be backwards.

curl

curl -X POST https://drop.top/api/v1/pushers/{id}/alerts \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"title":"<title>"}'

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}/alerts', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer sk_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({"title":"<title>"}),
});
const data = await res.json();
GET /api/v1/pushers/{id}/alerts/{alertId}

Fetch one alert

alerts:read MCP: get_alert

Parameters

NameInType
id * path string
alertId * path string

Responses

200

OK

404

Not found

curl

curl https://drop.top/api/v1/pushers/{id}/alerts/{alertId} \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}/alerts/{alertId}', {
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
const data = await res.json();
PATCH /api/v1/pushers/{id}/alerts/{alertId}

Edit, send or cancel an alert

alerts:write MCP: update_alert

The body is EITHER {"action": "send"} / {"action": "cancel"} OR a partial edit of the fields — never both. Sending is a state change on a row the caller already identified, which is why it is an action on this PATCH rather than its own route: two paths that load-and-authorise the same object is how one of them ends up wrong. Over MCP the forms are separate tools: update_alert for the edit, send_alert and cancel_alert for the actions.

Both actions require alerts:send. Send is the obvious one; cancel is the same authority because withdrawing changes what reaches phones — a write-only integration must not be able to retract a live evacuation notice it could never have issued.

action: send on an already-SENT alert is idempotent, not an error — a double-tapped Send button, or a client retrying a request that timed out after it succeeded, must not read as a failure, and the status guard underneath means it cannot re-notify. A CANCELLED alert cannot be re-sent (409 — duplicate it instead), and sending refuses while the pusher is not active (409).

A sent alert is not editable, and it is not a limitation to be worked around later. The moment it is SENT the text is on lock screens and — for an urgent one — has already spoken itself aloud in somebody's kitchen. Editing the row would change what the next phone to poll receives while every phone that already has it shows the old words: two versions of an evacuation instruction in circulation with no way to tell which one you got. Cancel it and send a correction. Cancelled alerts are equally frozen — duplicate instead.

Cancelling answers with a note stating exactly what happened: the alert stops reaching anyone who has not polled yet, and the phones that already received it keep it. It is not a recall, and the response says so because an integration calling this from a script deserves the same honesty a human gets in the studio.

On a SCHEDULED alert, an edit that moves send_at into the future keeps it scheduled; an edit that clears send_at drops it back to a draft. Recomputed rather than preserved, so the status can never disagree with the timestamp it describes.

Parameters

NameInType
id * path string
alertId * path string

Request body

FieldTypeDescription
action send | cancel

Requires alerts:send, both of them.

title string
body string | null
priority normal | important | urgent

A ceiling, not a command — and it defaults to the quiet one, which is the only reading under which a caller that never mentions loudness cannot accidentally be loud. An explicitly wrong value is still a 422; there is no guessing, only an absence with an obvious meaning.

layout text | image | html | document
image_url string | null

https only. Satisfies layout: image on its own, as does image_asset_id; one of the two is required for that layout.

image_asset_id string | null

A picture UPLOADED to this service rather than linked. The alert then travels with image_url pointing at our own media route, so nothing on the phone distinguishes the two — and this field wins where both are set.

THE ID IS MINTED IN THE STUDIO, NOT HERE. Uploading is a browser problem — a file on somebody's desktop — and the three calls that do it are session-authenticated and deliberately outside this contract, for the same reason cover_url exists and a cover upload endpoint does not. A script already has a URL for its picture and should send image_url. This field is documented because the studio composes through this endpoint and you will see it on the wire.

Sending null detaches an uploaded picture from a draft.

audio_asset_id string | null

The same arrangement for the alert's sound file: an upload we hold, surfaced to the phone as audio_url. Wins over audio_url where both are set, and null detaches it.

html_asset_id string | null

An uploaded .html file, kept as the author wrote it. IT IS NOT WHAT PHONES RENDER: the file's text is put through exactly the sanitiser (or, for layout: document, the sandbox) that html below goes through, and the result is what is stored and sent. The file itself is a record you can download back, served as text/plain and as an attachment — never as markup, from any origin.

Send it INSTEAD of html and the file's contents become the body. Send BOTH and html wins — the file is then only a record of where the body came from. That order exists because the studio uploads a file, shows its text in an editable box, and sends the box: if the file won, every edit made after uploading would vanish at the moment of sending. Null detaches.

html string | null

Required when layout is html or document. For html it is sanitised (at most 60,000 characters) and the removals come back in blocked; for document the page is kept whole up to four times that, rendered in the phone's sandboxed iframe where scripts are inert. POST /v1/drop/preview runs the identical code without storing anything.

actions AlertAction[]
requires_ack boolean

Puts the acknowledgement button on the card. Combined with expires_at, the window must stay open at least 5 minutes — the reader may be asleep, and an acknowledgement window shorter than the time it takes to pick up a phone produces a dashboard that says nobody responded to an evacuation instruction.

send_at string | null

With send: true, a future instant schedules; the alert becomes visible at that moment with no scheduler involved, so a server that was down at the minute delivers late rather than never.

expires_at string | null

"Stop treating this as current", not "pretend it never happened" — phones move it out of the spotlight and stop ringing, but a reader can still scroll back to last night's storm warning. Must be after send_at.

sound default | chime | ping | bell | knock | alarm | siren | silent | null

A tone id, not a file — the phone synthesises it, so it costs no asset, no decode latency, and no network at the one moment the phone may have none. default and anything unrecognised both store null, meaning "whatever this priority already sounds like": a retired tone id loses the tone, never the alert. silent is a choice, not an absence. Heard only if the channel asked for sound AND the subscriber granted it — otherwise the alert arrives quietly and the create response's notes says so.

audio_url string | null

An absolute https URL to an mp3 — or any audio the handset can decode — STREAMED at the moment the alert arrives. A jingle, the siren a city already uses, a recorded sentence in a voice the reader knows.

IT WINS OVER sound, AND sound IS ITS FALLBACK. The phone gives the fetch a few seconds; if it is slow, refused, unplayable or the phone has no network, the tone id plays instead. So attaching a file can never be the reason an alert was silent — which is the only arrangement under which streaming audio belongs in an emergency path at all.

Not proxied, not cached and not transcoded: the URL travels to the phone exactly as written and the phone fetches it from wherever you put it, so it must be publicly reachable with no login. A phone plays at most 20 seconds. Heard only where the channel asked for sound AND the subscriber granted it — the create response's notes says so when it did not.

vibrate default | none | short | double | long | sos | heartbeat | null

Same rules as sound: default and unknowns store null ("what this priority already does"); none stores, because "do not buzz" is a choice and not an absence.

tts_text string | null

What a phone reads aloud, where the channel asked for tts and the subscriber granted it. 400 characters because that is what the phone's speech call clamps to — storing more would store a sentence cut off at the moment it is spoken.

tts_lang string | null

BCP-47, e.g. "nl-BE". Null falls back to the phone's own language.

lat number | null

An alert's own circle — all three of lat/lon/radius_km or none, exactly like the channel's. It may NARROW the channel's area and never widen it: the subscribe screen already told every subscriber where this channel operates, and an alert reaching past that would make the one disclosure they read a false one. A channel with no circle of its own may target anywhere.

lon number | null
radius_km number | null

Kilometres, decimal — 0.05 is a 50-metre circle around one platform or one gate, and that is a supported thing to want. Containment against the channel area allows 2% of the CHANNEL radius, at least 10 m: enough that a circle poking a few metres over a hand-drawn boundary is accepted, not enough to double the area. Note what the number cannot buy — a handset locates itself to within roughly 5-20 m outdoors and tens of metres indoors, so a circle smaller than that alerts some people just outside it and misses some just inside it.

geo_rule string | null
collapse_key string | null

An author label — "storm-14", "gate-change" — that makes a newer alert replace an older one in the notification shade instead of piling up. A slug on purpose: the value is hashed into a notification id on two sides of a language boundary, and restricting the alphabet is what stops a key that round-trips differently producing two entries where the author expected one.

Responses

200

The alert after the edit or action.

403

The credential lacks a scope this request needs — error.type is insufficient_scope and the message names the scope and the place to fix it (mint a key carrying it, or disconnect and reconnect the assistant, approving it this time). Distinct from forbidden so a client can tell "get a better credential" apart from "this is not yours".

404

Not found

409

The state refuses the verb: sending a CANCELLED alert, sending while the pusher is not active, or editing a SENT or CANCELLED alert. The message names the way out (resume, duplicate, or cancel-and-correct).

422

Validation failed

429

Over a ceiling. The message names it and when to retry. These limits fail open during a cache outage on purpose — the caller is authenticated and refusing real work to protect a counter would be backwards.

curl

curl -X PATCH https://drop.top/api/v1/pushers/{id}/alerts/{alertId} \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"action":"send"}'

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}/alerts/{alertId}', {
  method: 'PATCH',
  headers: { 'Authorization': 'Bearer sk_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({"action":"send"}),
});
const data = await res.json();
DELETE /api/v1/pushers/{id}/alerts/{alertId}

Delete a draft or scheduled alert

alerts:write MCP: delete_alert

A SENT alert is a record and is not deletable (409). It is what a school points at when asked whether it told the parents, and what a city points at when asked when it warned people — and subscribers' acknowledgements reference it, so deleting it would erase somebody's acknowledgement along with it. Cancel takes it out of circulation; the row stays. Drafts, scheduled and cancelled alerts delete normally.

Parameters

NameInType
id * path string
alertId * path string

Responses

204

Deleted

404

Not found

409

The alert was sent — it is the record of what was said and when.

curl

curl -X DELETE https://drop.top/api/v1/pushers/{id}/alerts/{alertId} \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}/alerts/{alertId}', {
  method: 'DELETE',
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
if (!res.ok) throw new Error(await res.text());
POST /api/v1/drop/preview

Preview alert HTML through the real sanitiser

alerts:write

The write path's own sanitiser, as a preview. A preview rendered from your raw input would be a lie twice over — it would show markup the phone will never render, and it would run markup in your own browser that the write path exists to neuter. This round-trips through the literal functions the write path calls, so it cannot drift from what a phone will actually get. It stores nothing and sends nothing.

Under /v1 rather than a studio-internal route deliberately: key-authenticated, so a script or an MCP-driven model composing HTML can see the honest result too. For layout: html you get the cleaned fragment plus blocked, the removal report. For layout: document the page is kept whole — it renders inside the phone's sandboxed iframe where scripts are inert — so nothing is blocked and script_count tells you how many scripts will be carried, inert.

Request body

FieldTypeDescription
html * string

At most 60,000 characters for html, four times that for document — the same caps the write path enforces.

layout html | document

Responses

200

The sanitised result — exactly what an alert body would store.

422

Validation failed

429

Over a ceiling. The message names it and when to retry. These limits fail open during a cache outage on purpose — the caller is authenticated and refusing real work to protect a counter would be backwards.

curl

curl -X POST https://drop.top/api/v1/drop/preview \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"html":"<html>"}'

fetch

const res = await fetch('https://drop.top/api/v1/drop/preview', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer sk_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({"html":"<html>"}),
});
const data = await res.json();

Analytics

The numbers, named for what they are. reach is subscribers NOW ("could reach"), reads/acks are self-reports from devices that chose to send one, and there is no "delivered" anywhere because no table can back one. Buckets are UTC calendar days — the rollups are written at event time, where no account context exists — and every response says "timezone": "UTC" so nobody has to guess.

GET /api/v1/pushers/{id}/alerts/{alertId}/stats

One alert's read timeline

analytics:read

Aggregate buckets of read and ack reports over time — 30-minute buckets for the first six hours after sending, hourly after that. Built from the per-device state reports that still exist: devices prune their oldest reports, so an old alert's timeline thins out honestly, and the note field says so on every response rather than only here — a chart that silently decays looks like a bug. The reads/acks totals come from counters and do not decay; only the timeline's shape does. Past 10,000 surviving reports the timeline is a truthful-but-sampled view; the totals are exact either way.

Parameters

NameInType
id * path string
alertId * path string

Responses

200

OK

404

Not found

curl

curl https://drop.top/api/v1/pushers/{id}/alerts/{alertId}/stats \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}/alerts/{alertId}/stats', {
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
const data = await res.json();
GET /api/v1/pushers/{id}/stats

One channel's aggregate story

analytics:read

Honest numbers only, named accordingly: reads and acks are self-reports from devices that chose to send one; per-alert reach is the subscriber count NOW, which the studio prints as "could reach" — there is no per-device delivery row anywhere in this product and therefore no "delivered" figure, deliberately. There is also no computed read-rate across alerts: reach-then ≠ reach-now, and a percentage we cannot stand behind is worse than two honest integers side by side.

Ranges are UTC calendar days — unlike the audio /v1/stats, which resolves in your own zone — because these buckets are written at event time, where no creator context exists. The response says "timezone": "UTC" so nobody has to guess at the difference.

When a plan limits how far back analytics may look, the range is clamped and window_days_max appears saying so — visible on the wire rather than silent, because a shorter chart with no explanation reads as data loss. Until billing lands nothing clamps and the field is absent.

Parameters

NameInType
id * path string
period query day | week | month
from query string (date)
to query string (date)

Responses

200

OK

404

Not found

422

Validation failed

curl

curl https://drop.top/api/v1/pushers/{id}/stats \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}/stats', {
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
const data = await res.json();
GET /api/v1/pushers/{id}/subscribers

Pseudonymous subscriber records

subscribers:read

What a channel may see about the people it can reach, and the line it may never cross: platform, app version, locale, timezone, when they subscribed, whether they are muted. Never a name, an email, a location, or per-device engagement — the subscribe screen promises "the channel never learns who you are", and this endpoint is that promise kept.

device is a hash, never the raw id, and that is load-bearing. The raw device id is the claim a phone authenticates with — a write-credential-shaped value: whoever holds it can forge that device's read and ack reports and rewrite its subscriptions. So the wire carries HMAC-SHA256 of it under a server secret, truncated to 10 hex characters: stable (you can follow a7f39c1b2e across pages), useless to replay.

Keyset-paged on subscribed_at like the alert list; breakdown is computed over the whole subscription set, not the page.

Parameters

NameInTypeDescription
id * path string
limit query integer
before query string (date-time)

An ISO 8601 instant — subscriptions created strictly before it.

status query active | muted

Responses

200

OK

404

Not found

422

Validation failed

curl

curl https://drop.top/api/v1/pushers/{id}/subscribers \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/pushers/{id}/subscribers', {
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
const data = await res.json();
GET /api/v1/drop/stats

Account-level statistics

analytics:read MCP: get_stats

Every channel you may see, summed, plus a per-channel breakdown for a "by channel" rail. Same honesty contract as /v1/pushers/{id}/stats — reads/acks are device reports, reach is now-not-then, UTC days, window_days_max appears when a plan clamps — read that operation's description first. "Every channel you may see" is literal: a member's channel restriction and a bound key's channel are folded in, so the account view is not a side door around either.

The path lives under /v1/drop rather than /v1/pushers/stats because the latter would collide with /v1/pushers/{id} routing.

Parameters

NameInType
period query day | week | month
from query string (date)
to query string (date)

Responses

200

OK

422

Validation failed

curl

curl https://drop.top/api/v1/drop/stats \
  -H "Authorization: Bearer sk_live_..."

fetch

const res = await fetch('https://drop.top/api/v1/drop/stats', {
  headers: { 'Authorization': 'Bearer sk_live_...' },
});
const data = await res.json();

Publishing is free

Create an account, pick a handle, and the API is yours.

Start a channel