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.type | HTTP | Meaning |
|---|---|---|
invalid_request |
422 | Validation failed. |
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 |
forbidden |
403 | Not your role or your data. A different word from |
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:
| What | Limit | Why 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
/api/v1/me
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
/api/v1/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();
/api/v1/webhooks
Request body
| Field | Type | Description |
|---|---|---|
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:
|
Responses
201 |
Created. |
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();
/api/v1/webhooks/{webhook_id}
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
| Name | In | Type |
|---|---|---|
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.
/api/v1/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();
/api/v1/pushers
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
| Field | Type | Description |
|---|---|---|
handle * |
string |
|
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: |
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[] |
|
alert_types |
string[] |
|
tags |
string[] |
Lowercased, deduplicated. Searched alongside the name. |
is_public |
boolean |
The default is |
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();
/api/v1/pushers/{id}
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
| Name | In | Type |
|---|---|---|
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();
/api/v1/pushers/{id}
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
| Name | In | Type |
|---|---|---|
id * |
path | string |
Request body
| Field | Type | Description |
|---|---|---|
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: |
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[] |
|
alert_types |
string[] |
|
tags |
string[] |
Lowercased, deduplicated. Searched alongside the name. |
is_public |
boolean |
The default is |
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. |
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();
/api/v1/pushers/{id}
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
| Name | In | Type |
|---|---|---|
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());
/api/v1/pushers/{id}/invites
The ways in to a private channel
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
| Name | In | Type |
|---|---|---|
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();
/api/v1/pushers/{id}/invites
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
| Name | In | Type |
|---|---|---|
id * |
path | string |
Request body
| Field | Type | Description |
|---|---|---|
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();
/api/v1/pushers/{id}/invites/{inviteId}
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
| Name | In | Type | Description |
|---|---|---|---|
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").
/api/v1/pushers/{id}/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
| Name | In | Type | Description |
|---|---|---|---|
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();
/api/v1/pushers/{id}/alerts
Compose an alert — draft, schedule or send
One POST, three outcomes, because they are one action with one validation:
| body | result |
|---|---|
send: false (or absent) | draft — saved, invisible |
send: true, future send_at | scheduled — visible from that instant, no scheduler involved |
send: true, no send_at | sent — 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
| Name | In | Type |
|---|---|---|
id * |
path | string |
Request body
| Field | Type | Description |
|---|---|---|
send |
boolean |
The irreversible bit. Requires |
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 |
image_asset_id |
string | null |
A picture UPLOADED to this service rather than linked. The alert then travels with 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 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 |
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 Send it INSTEAD of |
html |
string | null |
Required when |
actions |
AlertAction[] |
|
requires_ack |
boolean |
Puts the acknowledgement button on the card. Combined with |
send_at |
string | null |
With |
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 |
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. |
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 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 |
vibrate |
default | none | short | double | long | sos | heartbeat | null |
Same rules as |
tts_text |
string | null |
What a phone reads aloud, where the channel asked for |
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 |
lon |
number | null |
|
radius_km |
number | null |
Kilometres, decimal — |
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 |
403 |
Either |
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();
/api/v1/pushers/{id}/alerts/{alertId}
Parameters
| Name | In | Type |
|---|---|---|
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();
/api/v1/pushers/{id}/alerts/{alertId}
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
| Name | In | Type |
|---|---|---|
id * |
path | string |
alertId * |
path | string |
Request body
| Field | Type | Description |
|---|---|---|
action |
send | cancel |
Requires |
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 |
image_asset_id |
string | null |
A picture UPLOADED to this service rather than linked. The alert then travels with 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 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 |
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 Send it INSTEAD of |
html |
string | null |
Required when |
actions |
AlertAction[] |
|
requires_ack |
boolean |
Puts the acknowledgement button on the card. Combined with |
send_at |
string | null |
With |
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 |
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. |
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 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 |
vibrate |
default | none | short | double | long | sos | heartbeat | null |
Same rules as |
tts_text |
string | null |
What a phone reads aloud, where the channel asked for |
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 |
lon |
number | null |
|
radius_km |
number | null |
Kilometres, decimal — |
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 — |
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();
/api/v1/pushers/{id}/alerts/{alertId}
Delete a draft or scheduled 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
| Name | In | Type |
|---|---|---|
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());
/api/v1/drop/preview
Preview alert HTML through the real sanitiser
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
| Field | Type | Description |
|---|---|---|
html * |
string |
At most 60,000 characters for |
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.
/api/v1/pushers/{id}/alerts/{alertId}/stats
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
| Name | In | Type |
|---|---|---|
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();
/api/v1/pushers/{id}/stats
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
| Name | In | Type |
|---|---|---|
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();
/api/v1/pushers/{id}/subscribers
Pseudonymous subscriber records
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
| Name | In | Type | Description |
|---|---|---|---|
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();
/api/v1/drop/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
| Name | In | Type |
|---|---|---|
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();