One request, one sound.
Two credentials and one POST. Everything under that is the second question rather than the first: what happens when the same alert fires forty times, when nobody answers, and when the job that should have run did not.
curl https://acciti.com/v1/messages \ -H "Authorization: Bearer $ACCITI_TOKEN" \ -d '{ "user": "your-user-key", "message": "Payment received" }'Quickstart
Three minutes, ending with a noise. Nothing below is needed to get there.
- 1
Sign in and install an app
Sign in with a passkey at acciti.com, then install Acciti on your iPhone or Mac and sign in there too. Installing is what registers the device that will make the noise.
- 2
Copy your user key
It is on the dashboard and in the app. This is the address senders send to. It can only send to you, and it can never read what you have received, so it is safe to paste into a script.
- 3
Make a sender
One per script or service, each with its own token. It authorises sending and nothing else. Revoke a leaked one and the rest keep working.
- 4
POST
The request at the top of this page, with your two values in it. The phone in your pocket makes a noise about two seconds later.
A 202 came back and nothing arrived? The message was accepted and has nowhere to go, which is what "deviceCount": 0 means. Sign in on a device.
Credentials
Three kinds, and they are not interchangeable. Sending uses one of them; the other two exist so a leak costs you as little as possible.
- User key
- The recipient. You hand it to whoever should be able to reach you. It reaches every device you have signed in on, and you can rotate it: the old string stops working and everything already received stays where it is.
- Somebody holding it can send to you. They cannot read anything.
- Application token
- The sender. Authorises sending and nothing else: it cannot read your messages, list your devices or retire them. One per script.
- Somebody holding it can send in that sender's name. Revoke it and the other senders are untouched.
- Account token
- You, to the API. This is what the dashboard and the apps hold, and what reads your inbox, your teams and your billing. A session expires; one made for a script does not until it is revoked.
- Somebody holding it is you. Never put one in a webhook handler; that is what an application token is for.
All three travel as Authorization: Bearer. The server decides what a token is from the token, so there is no header to get wrong, and a session left in a browser expires after 30 days.
Sending a message
POST /v1/messages with an application token. Two fields are required: who it is for, and what it says.
curl https://acciti.com/v1/messages \ -H "Authorization: Bearer $ACCITI_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "user": "your-user-key", "title": "Payment received", "message": "$49.00 from a new subscriber", "sound": "chime" }'import os, httpx httpx.post( "https://acciti.com/v1/messages", headers={"Authorization": f"Bearer {os.environ['ACCITI_TOKEN']}"}, json={ "user": "your-user-key", "title": "Payment received", "message": "$49.00 from a new subscriber", "sound": "chime", },).raise_for_status()await fetch("https://acciti.com/v1/messages", { method: "POST", headers: { "Authorization": `Bearer ${process.env.ACCITI_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ user: "your-user-key", title: "Payment received", message: "$49.00 from a new subscriber", sound: "chime", }),})var request = URLRequest(url: URL(string: "https://acciti.com/v1/messages")!)request.httpMethod = "POST"request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")request.setValue("application/json", forHTTPHeaderField: "Content-Type")request.httpBody = try JSONSerialization.data(withJSONObject: [ "user": "your-user-key", "title": "Payment received", "message": "$49.00 from a new subscriber", "sound": "chime",])_ = try await URLSession.shared.data(for: request)body, _ := json.Marshal(map[string]any{ "user": "your-user-key", "title": "Payment received", "message": "$49.00 from a new subscriber", "sound": "chime",})req, _ := http.NewRequest("POST", "https://acciti.com/v1/messages", bytes.NewReader(body))req.Header.Set("Authorization", "Bearer "+os.Getenv("ACCITI_TOKEN"))req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)A 202 means accepted, never delivered. Delivery happens outside the request, so your own webhook handler never waits on Apple.
{ "id": "e4ddc35f-a42b-41b7-ae0e-033794827b4b", "status": "accepted", "deviceCount": 3}A deviceCount of zero means the message was accepted and has nowhere to go, which is what a recipient with no registered device looks like. Reported rather than hidden, because from the sender's side it is otherwise indistinguishable from working.
user- The recipient's user key. Required.
message- The body, up to 1024 characters. Required.
title- Up to 128 characters. Defaults to the sender's name, so a recipient with several senders can tell who is talking.
sound- A sound this team has. Refused at send time if it does not.
priority- -2 to 2. Defaults to 0.
url, urlTitle- A link on the notification, and what to call it.
ttl- Seconds. After this the message is dropped rather than shown late, because a stale alert is worse than none.
retry, expire- Priority 2 only. How often to re-alert, and when to stop.
dedupKey, dedupWindow- Your name for the condition, and how long it stays open.
escalation- The name of an on-call policy belonging to the sending team.
Priorities and quiet hours
Every send carries a priority. It decides how hard the notification tries to reach somebody, not how quickly it is delivered.
-2Silent
Delivered and stored with no sound and no banner. It waits in the inbox.
-1Quiet
A banner without a sound.
0Normal
A banner and the sound the sender named. The default.
1High
Breaks through a Focus mode and through the recipient's quiet hours.
2Emergency
Repeats until somebody acknowledges it on one of their devices, and climbs an on-call ladder if nobody does.
Quiet hours are the recipient's, not the sender's, and they are set on the device that makes the noise. Between the times somebody chooses, an ordinary alert arrives silently and waits in the inbox. Priority 1 and 2 go through regardless, which is what those two levels are for: a sender reaching for 1 is saying this is worth the interruption, and quiet hours that could override it would make the level meaningless.
Alerts that keep ringing
Priority 2 keeps going until a person says they have it. Send retry and expire with it: how often to alert again, and when to give up.
{ "user": "your-user-key", "title": "api.acme.com is down", "message": "No response for 90 seconds", "priority": 2, "retry": 60, "expire": 1800, "escalation": "production"}Both are required rather than defaulted, because no one interval suits both a disk filling up and a payment gateway that has stopped answering. Retry is at least 30 seconds and expire at most 3 hours.
The repeat is the same notification rather than a new one, so the lock screen keeps one entry that re-alerts instead of collecting thirty identical banners. Acknowledging on any device stops it on all of them, and stops the on-call ladder with it.
curl -X POST https://acciti.com/v1/messages/$MESSAGE_ID/acknowledge \ -H "Authorization: Bearer $ACCITI_ACCOUNT_TOKEN"A recipient who has seen it but cannot act yet can snooze it instead, from the notification itself. That pauses the repeats and the ladder together, for up to an hour, and never past the expire you set. Snoozing is not acknowledging: the alert comes back.
On-call ladders
Name a policy on the send and an unacknowledged alert walks down it. Each step waits, then wakes the next people on its own, and the first person to acknowledge stops the whole thing on every device it reached.
A step can point at a rotation rather than at a person, and the rotation is read when the step fires. Somebody going away adds a cover and the ladder follows it, with the policy untouched.
curl https://acciti.com/v1/messages \ -H "Authorization: Bearer $ACCITI_TOKEN" \ -d '{ "user": "your-user-key", "title": "api.acme.com is down", "message": "No response for 90 seconds", "priority": 2, "retry": 60, "expire": 1800, "escalation": "production" }'A policy name the team does not have is refused with unknown_escalation rather than sent as a plain alert. A send that asked for a ladder and quietly did not get one looks exactly like one that worked, and the difference only shows up on the night it matters.
Policies and rotations are built on the dashboard rather than through the API, and the on-call page draws what one looks like.
Sending the same event twice
Webhook handlers retry. Stripe alone will redeliver an event for three days, and without help that is one payment and several notifications.
curl https://acciti.com/v1/messages \ -H "Authorization: Bearer $ACCITI_TOKEN" \ -H "Idempotency-Key: evt_1PxYzABCDEF" \ -d '{ "user": "your-user-key", "message": "Payment received" }'Pass something stable from the event itself, usually its id. A repeat returns the original message instead of sending another, and does not count against your allowance.
The same problem, over and over
A service that flaps reports one problem many times. An Idempotency-Key does not help here, because each send is genuinely a new event. What repeats is the condition.
curl https://acciti.com/v1/messages \ -H "Authorization: Bearer $ACCITI_TOKEN" \ -d '{ "user": "your-user-key", "message": "api.acme.com is not responding", "dedupKey": "api-acme-down", "dedupWindow": 600 }'The first send alerts. Repeats inside the window collapse into it and raise a count instead of ringing again, and the response says "status": "collapsed" with that alert's id. The window is measured from the first alert, so a service flapping every second is heard once every window rather than never again. It defaults to 5 minutes and is capped at a day. Acknowledging closes the key, so the next occurrence alerts again.
Fifty different alerts in five minutes are not repeats and cannot be collapsed without losing them. Past ten from one sender to one person within five minutes, the rest arrive and are kept but stop making a noise, with "silenced": "storm" on the response saying so. Emergencies are never silenced this way.
When nothing arrives
Everything above is something going wrong that announces itself. A backup that stops running announces nothing, and a cron job removed by a deploy announces nothing. You find those weeks later, the day you reach for the backup.
Create a heartbeat on the dashboard, choose how often you expect a check-in and how much lateness to allow, and put its URL at the end of the job.
0 3 * * * /usr/local/bin/backup.sh && \ curl -fsS https://acciti.com/v1/heartbeats/$HEARTBEAT_TOKEN/pingGET or POST, no headers and no body, so it works from anything that can make a request. The && matters: ping only when the job actually succeeded, or you are monitoring whether cron ran rather than whether the backup worked.
Intervals run from a minute to a month, with up to a day of grace. When a ping does not come within the interval plus the grace, you hear about it once, through the same priorities and on-call policies as any other alert. When it checks in again you get a quiet note saying how long it was gone. Every other ping says nothing at all.
The URL is the whole credential, so treat it as a secret. Somebody holding it can keep a missed check-in quiet, and nothing more: it reads nothing, sends nothing, and cannot make a noise on anybody's phone.
What to put a heartbeat onLetting a service send
A connector is a URL somebody else's service posts to. Acciti verifies it, matches it against a rule, and sends the notification, so there is no handler of your own between the two.
8 kinds today: App Store, Stripe, GitHub, Provisore, Sentry, Grafana, Better Stack, Custom. Each has its own way of proving a payload is genuine and its own catalogue of events. Most sign the body, which proves both who sent it and that nothing changed in transit. Grafana and Better Stack authenticate with a bearer secret in a header instead, because Alertmanager and uptime monitors sign nothing, and a secret that travels on every request can be replayed by anything that can read one.
The Custom kind is the escape hatch. Sign the raw body with the secret Acciti issues and send the hex digest:
BODY='{"event":"disk.full","host":"db-1"}'SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$ACCITI_SECRET" -hex | awk '{print $2}')curl https://acciti.com/v1/ingest/$CONNECTOR_ID \ -H "Content-Type: application/json" \ -H "X-Acciti-Signature: sha256=$SIGNATURE" \ -d "$BODY"import hashlib, hmac, json, os, httpx body = json.dumps({"event": "disk.full", "host": "db-1"}).encode()signature = hmac.new(os.environ["ACCITI_SECRET"].encode(), body, hashlib.sha256).hexdigest() httpx.post( f"https://acciti.com/v1/ingest/{connector_id}", content=body, headers={ "Content-Type": "application/json", "X-Acciti-Signature": f"sha256={signature}", },).raise_for_status()Sign the bytes you actually send. A body serialised twice is two different strings, and the second signature will not match the first.
An event no rule matches is recorded and rings nobody. That is a normal outcome rather than a failure, and the dashboard lists the types a connector has received and has no rule for.
Every connector, and what each sendsSounds
Name a sound and the recipient hears that one. It is how a sale and an outage become distinguishable from across the room, and it is usually set per sender rather than per message.
A sound the recipient does not have is refused at send time rather than arriving silently, so a typo surfaces where you can still fix it.
Upload your own on the dashboard, where you can also play them back. AIFF, WAV or CAF, under 512KB. Those are the three containers APNs will play, and it will not play anything over thirty seconds, so a longer file arrives with no sound at all.
Uploading over a name replaces it, and every device fetches the new audio rather than playing a cached copy of the old.
Devices hold your team's sounds before they are needed rather than fetching one when an alert arrives. The system reads the file while it draws the notification, so a sound fetched at that moment is one that never plays, and the night a custom sound matters is the night the phone has one bar. A sound a device has not picked up yet falls back to the system default.
Finding out what happened
GET /v1/messages/{id} returns a receipt per device: delivered, pending, or failed with the reason Apple gave.
{ "id": "e4ddc35f-a42b-41b7-ae0e-033794827b4b", "deliveries": [ { "device": "Jacob's iPhone", "status": "delivered" }, { "device": "Studio Mac", "status": "failed", "reason": "BadDeviceToken" } ]}Each receipt also carries how long it took, and separately how long it spent being retried. A delivery slow because the queue was busy and one slow because Apple refused it four times are different problems, and a single green tick hides both.
Recipients get one more thing on the dashboard and in the app: Why did I get this?, which lists every decision that routed the alert. The sender, the connector rule it matched, the rung of the on-call ladder that picked them, which rota said they were on call, how many repeats collapsed into it, and whether it was quiet and why. Each line is a fact recorded when it happened rather than a decision re-run later, so a rota that has since handed over does not name the wrong colleague.
When a send is refused
Every error has the same shape. The code is stable and worth branching on; the message is for a human and may be reworded. Quote the requestId when asking what happened.
{ "code": "unknown_user_key", "message": "That user key does not exist, or has been revoked.", "requestId": "01J9F3Q0YB4V7XZ2M8K6R5T1AC"}A 400 means the request was wrong and will be wrong again. Only 429 and a 5xx are worth retrying.
empty_message400
The body was missing, or was only whitespace.
Send something in `message`. A title alone is not a notification.
message_too_long400
The body was over 1024 characters.
Truncate it and put the rest behind `url`. A lock screen shows two lines.
invalid_priority400
The priority was not between -2 and 2.
Use one of the five. There is nothing above emergency.
emergency_needs_retry_and_expire400
Priority 2 arrived without both `retry` and `expire`.
Say how often to re-alert and when to give up. Neither is defaulted, because no one interval suits both a disk filling up and a payment gateway that has stopped answering.
retry_too_short400
`retry` was under 30 seconds.
Raise it. A re-alert every few seconds is how somebody learns to turn notifications off.
expire_too_long400
`expire` was over 3 hours.
Lower it. Past that, an alert nobody has answered is not going to be answered.
retry_exceeds_expire400
`retry` was longer than `expire`, so the alert would never repeat.
Make `retry` the smaller of the two.
retry_requires_emergency400
`retry` or `expire` arrived on a message below priority 2.
Refused rather than ignored: a sender who set these believes the message will repeat, and finding out otherwise during an incident is not the moment.
dedup_window_without_key400
`dedupWindow` arrived with no `dedupKey` to apply to.
Send the key as well, or drop the window.
url_title_without_url400
`urlTitle` arrived with no `url` to title.
Send the url, or drop the title.
invalid_ttl400
`ttl` was under one second.
Leave it out unless a stale alert is worse than none.
unknown_sound400
The sending team has no sound by that name.
Check the spelling, or upload it. Refused at send time rather than arriving silently, so a typo surfaces where it can still be fixed.
unknown_escalation400
The sending team has no on-call policy by that name.
Check the name on the dashboard. An alert that silently never escalates looks exactly like one that does.
unsupported_media_type400
The body was not JSON.
Set `Content-Type: application/json`.
unauthorized401
The application token is wrong, revoked, or missing.
Check the `Authorization: Bearer` header. Do not retry: a token does not come back.
plan_required402
The send named an escalation policy and the team's plan does not carry them. The body names `requiredPlan` and `requiredFeature`.
Upgrade, or send without the policy. The message itself was not sent, because an alert that quietly stops at the first person is not the alert that was asked for.
unknown_user_key404
No such recipient, or the key was rotated.
Ask them for their current key. Do not retry.
quota_exhausted429
The team has spent its allowance for the month. The message names the date it resets.
Wait for the reset or move up a plan. Going over is never billed as overage.
rate_limited429
Too many requests too quickly.
Back off and retry. This one is worth retrying; the 400s are not.
What needs which plan
A write that needs a capability the team's plan does not carry is refused with a 402 and "code": "plan_required". The body names which capability and the cheapest plan that would allow it, so a client can offer the upgrade without keeping its own copy of the plan table.
{ "code": "plan_required", "message": "Escalation policies need the Team plan.", "requestId": "01J9F3Q0YB4V7XZ2M8K6R5T1AC", "requiredFeature": "escalationPolicies", "requiredPlan": "team"}Reads and deletes are never gated, so a team that has moved down a plan can still see and tidy everything it configured. Nothing is deleted by a downgrade: heartbeats, connectors, rotations and escalation policies stop firing, stay visible, and work again when the plan comes back.
A message is one send, however many of your devices it reaches. The free plan allows 10,000 a month. Past the allowance, sends are refused with a 429 naming the date it resets, rather than billed.