{"openapi":"3.1.0","info":{"title":"Ding Publishing API","version":"1.0.0","description":"Personal alerts, batches of them, and announcements to everyone who connected your app; people choose Off, Inbox or Push per category in Ding. Register apps, verify domains and manage publishing keys at https://publish.ding.social/. Keys belong on the developer’s backend. A game uses its authenticated player’s DID; it never needs a player’s Ding session or device token. There is no hosted test-key environment, SDK, delivery-status API or webhook yet; use a separate app registration and consenting test account for integration tests."},"servers":[{"url":"https://publish.ding.social"}],"paths":{"/v1/app-events":{"post":{"operationId":"publishPersonalAlert","summary":"Send a personal alert to one connected DID","description":"Requires an approved, verified app and its server-side publishing key. Maximum JSON body: 8192 bytes. Acceptance does not disclose recipient existence, consent or delivery. Idempotency keys are app-scoped across both publishing endpoints and persist for the lifetime of the app registration; reusing one with different content or audience returns 409. Send the same key and payload when retrying after a timeout, 429 or 5xx. Respect Retry-After and use capped exponential backoff with jitter. Do not retry other 4xx without correcting the request.","security":[{"AppPublishingKey":[]}],"x-required-scope":"alerts","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"example":"match-42-turn-7-player-a","description":"Generate once per intended notification and reuse it for every retry. Not a recipient identifier."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["recipient","title"],"properties":{"recipient":{"type":"string","maxLength":2048,"pattern":"^did:[a-z]+:[^\\s/?#]+$","description":"Player DID from your authenticated AT Protocol session. Only an account that connected your app in Ding can receive it."},"title":{"type":"string","minLength":1,"maxLength":160,"description":"Plain text on one line; whitespace-only titles are rejected."},"body":{"type":"string","maxLength":2000,"description":"Optional plain text. Keep sensitive details in your authenticated app."},"url":{"type":"string","format":"uri","maxLength":2048,"pattern":"^https://","description":"HTTPS URL on the registered website’s exact origin, including port; no credentials."},"collapseKey":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$","description":"Optional. A newer alert with the same collapseKey, from your app to the same person, supersedes older ones: their pending pushes are cancelled and they are marked read. Use it for state that changes, such as whose turn it is."},"category":{"type":"string","minLength":1,"maxLength":40,"pattern":"^[a-z0-9][a-z0-9-]*$","description":"Optional. The id of one of the categories declared for your app in the publishing console, for example \"turns\". People choose Off, Inbox or Push per category in Ding; an alert in a category a person turned off is dropped. A categorised alert follows its category alone, on either endpoint. Without a category, a personal alert follows the person’s built-in “Alerts for you” switch (Push unless changed) and an announcement their “Announcements” switch (Off unless turned on). An undeclared id is rejected with unknown_category."},"subject":{"type":"string","maxLength":2048,"pattern":"^at://","description":"Optional at:// URI of the AT Protocol record or account the alert is about. Ding shows it with a link to inspect the record."},"actor":{"type":"string","maxLength":2048,"pattern":"^did:[a-z]+:[^\\s/?#]+$","description":"Optional DID of the person the alert is about, such as the player who challenged the recipient. Ding leads with that person (name and avatar) and the recipient’s mutes of them apply; write title as what they did, for example \"challenged you\", so it reads \"Dwight Schrute challenged you\"; the push title still names your app first (\"Chess · Dwight Schrute challenged you\"). Without actor the title is a whole sentence shown under your app’s name."},"image":{"type":"string","format":"uri","maxLength":2048,"pattern":"^https://","description":"Optional picture: HTTPS on the registered website’s exact origin, a PNG, JPEG or WebP of at most 1 MB answering 200 without redirects. Ding fetches it once when the alert is accepted and phones load Ding’s copy, never this address. A fetch that fails is 400 invalid_request with field image; Ding fetches at most 2000 pictures a day per app (then 429 rate_limited with field image)."},"actions":{"type":"array","maxItems":2,"description":"Optional buttons, in order: a link on your website’s exact origin, or an AT Protocol record the person can review and create through at.new. Record strings may hold {{...}} placeholders, for example {{$now}} or {{message}}.","items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["label","url"],"properties":{"label":{"type":"string","minLength":1,"maxLength":24,"description":"Plain text on one line. Ding’s own button names (Reply, Quote, Quote post, Follow, Follow back, Going, Interested, Not going, Subscribe, Unsubscribe, Secure account, This was me, Mute, Unmute, Watch, Report, Block, Connect, Disconnect) are refused, in any case."},"url":{"type":"string","format":"uri","maxLength":2048,"pattern":"^https://"}}},{"type":"object","additionalProperties":false,"required":["label","record"],"properties":{"label":{"type":"string","minLength":1,"maxLength":24},"record":{"type":"object","required":["$type"],"description":"At most 2048 bytes as JSON; $type is the record’s NSID.","properties":{"$type":{"type":"string","pattern":"^[a-z][a-z0-9-]*(\\.[a-z0-9-]+)+$"}}}}}]}},"expiresAt":{"type":"string","format":"date-time","description":"Optional RFC 3339 time, in the future and at most 7 days ahead. After it, Ding does not send the push; the alert stays in the inbox."}}},"example":{"recipient":"did:plc:PLAYER_DID","actor":"did:plc:OPPONENT_DID","title":"moved in match 42","body":"Your turn.","url":"https://example.com/matches/42","actions":[{"label":"Play","url":"https://example.com/matches/42"}]}}}},"responses":{"200":{"description":"Previously accepted request; no additional inbox entries, delivery jobs or publishing charges.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}},"Idempotent-Replayed":{"schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Accepted"},"example":{"id":"match-42-turn-7-player-a","accepted":true,"duplicate":true}}}},"202":{"description":"Request accepted. No promise that a push was sent or seen.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Accepted"},"example":{"id":"match-42-turn-7-player-a","accepted":true,"duplicate":false}}}},"400":{"description":"Invalid request, JSON, recipient or idempotency key.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked publishing key.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}},"WWW-Authenticate":{"schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the required scope (insufficient_scope), the app is not approved (app_not_approved), its owner account is inactive (owner_inactive), its domain proof lapsed (domain_unverified), or it is on hold while Ding reviews reports (app_on_hold).","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Key already accepted for a different payload or audience.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"JSON body exceeds 8192 bytes.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"415":{"description":"Use application/json.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Publishing quota reached.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds until all currently exceeded quota windows reset.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Transient server failure; safely retry the same key and payload.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/app-events/batch":{"post":{"operationId":"publishPersonalAlertBatch","summary":"Send up to 100 personal alerts in one request","description":"Each item is a personal-alert body plus its own id, which is that alert’s idempotency key: the same namespace and rules as the Idempotency-Key of a single send, so an item and a single send with the same id are the same alert. The batch’s own Idempotency-Key makes the whole request retryable: the same key and items return the stored results with Idempotent-Replayed: true; the same key with other items returns 409. Accepted items are stored in one transaction. A malformed item fails only itself, with its own code and field. The per-minute and per-day request limits count the batch once; the recipient limits count each new item, and a batch that would exceed them is refused whole with 429 (nothing stored). Maximum JSON body: 1048576 bytes, and 8192 bytes per item.","security":[{"AppPublishingKey":[]}],"x-required-scope":"alerts","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"example":"turns-2026-10-18T09:00","description":"Generate once per batch and reuse it for every retry of that batch."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["items"],"properties":{"items":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","additionalProperties":false,"required":["id","recipient","title"],"properties":{"id":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$","description":"This alert’s idempotency key."},"recipient":{"type":"string","maxLength":2048,"pattern":"^did:[a-z]+:[^\\s/?#]+$","description":"Player DID from your authenticated AT Protocol session. Only an account that connected your app in Ding can receive it."},"title":{"type":"string","minLength":1,"maxLength":160,"description":"Plain text on one line; whitespace-only titles are rejected."},"body":{"type":"string","maxLength":2000,"description":"Optional plain text. Keep sensitive details in your authenticated app."},"url":{"type":"string","format":"uri","maxLength":2048,"pattern":"^https://","description":"HTTPS URL on the registered website’s exact origin, including port; no credentials."},"collapseKey":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$","description":"Optional. A newer alert with the same collapseKey, from your app to the same person, supersedes older ones: their pending pushes are cancelled and they are marked read. Use it for state that changes, such as whose turn it is."},"category":{"type":"string","minLength":1,"maxLength":40,"pattern":"^[a-z0-9][a-z0-9-]*$","description":"Optional. The id of one of the categories declared for your app in the publishing console, for example \"turns\". People choose Off, Inbox or Push per category in Ding; an alert in a category a person turned off is dropped. A categorised alert follows its category alone, on either endpoint. Without a category, a personal alert follows the person’s built-in “Alerts for you” switch (Push unless changed) and an announcement their “Announcements” switch (Off unless turned on). An undeclared id is rejected with unknown_category."},"subject":{"type":"string","maxLength":2048,"pattern":"^at://","description":"Optional at:// URI of the AT Protocol record or account the alert is about. Ding shows it with a link to inspect the record."},"actor":{"type":"string","maxLength":2048,"pattern":"^did:[a-z]+:[^\\s/?#]+$","description":"Optional DID of the person the alert is about, such as the player who challenged the recipient. Ding leads with that person (name and avatar) and the recipient’s mutes of them apply; write title as what they did, for example \"challenged you\", so it reads \"Dwight Schrute challenged you\"; the push title still names your app first (\"Chess · Dwight Schrute challenged you\"). Without actor the title is a whole sentence shown under your app’s name."},"image":{"type":"string","format":"uri","maxLength":2048,"pattern":"^https://","description":"Optional picture: HTTPS on the registered website’s exact origin, a PNG, JPEG or WebP of at most 1 MB answering 200 without redirects. Ding fetches it once when the alert is accepted and phones load Ding’s copy, never this address. A fetch that fails is 400 invalid_request with field image; Ding fetches at most 2000 pictures a day per app (then 429 rate_limited with field image)."},"actions":{"type":"array","maxItems":2,"description":"Optional buttons, in order: a link on your website’s exact origin, or an AT Protocol record the person can review and create through at.new. Record strings may hold {{...}} placeholders, for example {{$now}} or {{message}}.","items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["label","url"],"properties":{"label":{"type":"string","minLength":1,"maxLength":24,"description":"Plain text on one line. Ding’s own button names (Reply, Quote, Quote post, Follow, Follow back, Going, Interested, Not going, Subscribe, Unsubscribe, Secure account, This was me, Mute, Unmute, Watch, Report, Block, Connect, Disconnect) are refused, in any case."},"url":{"type":"string","format":"uri","maxLength":2048,"pattern":"^https://"}}},{"type":"object","additionalProperties":false,"required":["label","record"],"properties":{"label":{"type":"string","minLength":1,"maxLength":24},"record":{"type":"object","required":["$type"],"description":"At most 2048 bytes as JSON; $type is the record’s NSID.","properties":{"$type":{"type":"string","pattern":"^[a-z][a-z0-9-]*(\\.[a-z0-9-]+)+$"}}}}}]}},"expiresAt":{"type":"string","format":"date-time","description":"Optional RFC 3339 time, in the future and at most 7 days ahead. After it, Ding does not send the push; the alert stays in the inbox."}}}}}},"example":{"items":[{"id":"match-42-turn-7","recipient":"did:plc:PLAYER_DID","title":"It is your turn","url":"https://example.com/matches/42"},{"id":"match-43-turn-2","recipient":"did:plc:PLAYER_DID","actor":"did:plc:OPPONENT_DID","title":"challenged you","body":"Blitz, 5 minutes."}]}}}},"responses":{"200":{"description":"Per-item results, in the order sent. Accepted items make no promise that a push was sent or seen. Idempotent-Replayed: true marks a replay of an earlier batch.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}},"Idempotent-Replayed":{"schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchResults"},"example":{"items":[{"id":"match-42-turn-7","accepted":true,"duplicate":false},{"id":"match-43-turn-2","accepted":false,"duplicate":false,"error":"title must be plain text on one line, 1–160 characters, without invisible or direction-override characters.","code":"invalid_request","field":"title"}]}}}},"400":{"description":"Invalid JSON, items list or batch Idempotency-Key.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked publishing key.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the alerts scope, or the app cannot publish (same codes as a single send).","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"This Idempotency-Key was already used for a batch with different items.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"JSON body exceeds 1048576 bytes.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"415":{"description":"Use application/json.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Publishing quota reached; nothing in the batch was stored.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds until all currently exceeded quota windows reset.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Transient server failure; safely retry the same key and items.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/app-announcements":{"post":{"operationId":"publishAnnouncement","summary":"Send to everyone who connected the app","description":"Requires an approved, verified app and its server-side publishing key. Maximum JSON body: 8192 bytes. Acceptance does not disclose recipient existence, consent or delivery. Idempotency keys are app-scoped across both publishing endpoints and persist for the lifetime of the app registration; reusing one with different content or audience returns 409. Send the same key and payload when retrying after a timeout, 429 or 5xx. Respect Retry-After and use capped exponential backoff with jitter. Do not retry other 4xx without correcting the request.","security":[{"AppPublishingKey":[]}],"x-required-scope":"announcements","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"example":"match-42-turn-7-player-a","description":"Generate once per intended notification and reuse it for every retry. Not a recipient identifier."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["title"],"properties":{"title":{"type":"string","minLength":1,"maxLength":160,"description":"Plain text on one line; whitespace-only titles are rejected."},"body":{"type":"string","maxLength":2000,"description":"Optional plain text. Keep sensitive details in your authenticated app."},"url":{"type":"string","format":"uri","maxLength":2048,"pattern":"^https://","description":"HTTPS URL on the registered website’s exact origin, including port; no credentials."},"collapseKey":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$","description":"Optional. A newer alert with the same collapseKey, from your app to the same person, supersedes older ones: their pending pushes are cancelled and they are marked read. Use it for state that changes, such as whose turn it is."},"category":{"type":"string","minLength":1,"maxLength":40,"pattern":"^[a-z0-9][a-z0-9-]*$","description":"Optional. The id of one of the categories declared for your app in the publishing console, for example \"turns\". People choose Off, Inbox or Push per category in Ding; an alert in a category a person turned off is dropped. A categorised alert follows its category alone, on either endpoint. Without a category, a personal alert follows the person’s built-in “Alerts for you” switch (Push unless changed) and an announcement their “Announcements” switch (Off unless turned on). An undeclared id is rejected with unknown_category."},"subject":{"type":"string","maxLength":2048,"pattern":"^at://","description":"Optional at:// URI of the AT Protocol record or account the alert is about. Ding shows it with a link to inspect the record."},"actor":{"type":"string","maxLength":2048,"pattern":"^did:[a-z]+:[^\\s/?#]+$","description":"Optional DID of the person the alert is about, such as the player who challenged the recipient. Ding leads with that person (name and avatar) and the recipient’s mutes of them apply; write title as what they did, for example \"challenged you\", so it reads \"Dwight Schrute challenged you\"; the push title still names your app first (\"Chess · Dwight Schrute challenged you\"). Without actor the title is a whole sentence shown under your app’s name."},"image":{"type":"string","format":"uri","maxLength":2048,"pattern":"^https://","description":"Optional picture: HTTPS on the registered website’s exact origin, a PNG, JPEG or WebP of at most 1 MB answering 200 without redirects. Ding fetches it once when the alert is accepted and phones load Ding’s copy, never this address. A fetch that fails is 400 invalid_request with field image; Ding fetches at most 2000 pictures a day per app (then 429 rate_limited with field image)."},"actions":{"type":"array","maxItems":2,"description":"Optional buttons, in order: a link on your website’s exact origin, or an AT Protocol record the person can review and create through at.new. Record strings may hold {{...}} placeholders, for example {{$now}} or {{message}}.","items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["label","url"],"properties":{"label":{"type":"string","minLength":1,"maxLength":24,"description":"Plain text on one line. Ding’s own button names (Reply, Quote, Quote post, Follow, Follow back, Going, Interested, Not going, Subscribe, Unsubscribe, Secure account, This was me, Mute, Unmute, Watch, Report, Block, Connect, Disconnect) are refused, in any case."},"url":{"type":"string","format":"uri","maxLength":2048,"pattern":"^https://"}}},{"type":"object","additionalProperties":false,"required":["label","record"],"properties":{"label":{"type":"string","minLength":1,"maxLength":24},"record":{"type":"object","required":["$type"],"description":"At most 2048 bytes as JSON; $type is the record’s NSID.","properties":{"$type":{"type":"string","pattern":"^[a-z][a-z0-9-]*(\\.[a-z0-9-]+)+$"}}}}}]}},"expiresAt":{"type":"string","format":"date-time","description":"Optional RFC 3339 time, in the future and at most 7 days ahead. After it, Ding does not send the push; the alert stays in the inbox."}}},"example":{"title":"Season 2 starts Friday","body":"Registration opens tomorrow."}}}},"responses":{"200":{"description":"Previously accepted request; no additional inbox entries, delivery jobs or publishing charges.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}},"Idempotent-Replayed":{"schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Accepted"},"example":{"id":"match-42-turn-7-player-a","accepted":true,"duplicate":true}}}},"202":{"description":"Request accepted. No promise that a push was sent or seen.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Accepted"},"example":{"id":"match-42-turn-7-player-a","accepted":true,"duplicate":false}}}},"400":{"description":"Invalid request, JSON, recipient or idempotency key.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked publishing key.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}},"WWW-Authenticate":{"schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the required scope (insufficient_scope), the app is not approved (app_not_approved), its owner account is inactive (owner_inactive), its domain proof lapsed (domain_unverified), or it is on hold while Ding reviews reports (app_on_hold).","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Key already accepted for a different payload or audience.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"JSON body exceeds 8192 bytes.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"415":{"description":"Use application/json.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Publishing quota reached.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds until all currently exceeded quota windows reset.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Transient server failure; safely retry the same key and payload.","headers":{"X-Request-Id":{"description":"Server-generated ID for this HTTP attempt, also included in error bodies.","schema":{"type":"string"}},"RateLimit-Policy":{"description":"IETF RateLimit policies for this endpoint and key environment, for example \"minute\";q=120;w=60, \"day\";q=20000;w=86400.","schema":{"type":"string"}},"RateLimit":{"description":"Remaining requests (r) and seconds until reset (t) for each policy.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"securitySchemes":{"AppPublishingKey":{"type":"http","scheme":"bearer","description":"App-scoped secret with the alerts and/or announcements scope; not a user’s Ding session. ding_sk_live_ keys reach everyone who connected the app. ding_sk_test_ keys run every check, return real responses with Ding-Test-Mode: true and test: true, deliver only to the app owner’s own account, and have their own limits. Each environment allows two active keys for zero-downtime rotation."}},"schemas":{"Accepted":{"type":"object","additionalProperties":false,"required":["id","accepted","duplicate"],"properties":{"id":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"accepted":{"type":"boolean","const":true},"duplicate":{"type":"boolean"},"test":{"type":"boolean","const":true,"description":"Present when a test key was used."}}},"BatchResults":{"type":"object","additionalProperties":false,"required":["items"],"properties":{"items":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["id","accepted","duplicate"],"properties":{"id":{"type":["string","null"],"description":"The item’s id, or null when it had none."},"accepted":{"type":"boolean"},"duplicate":{"type":"boolean"},"error":{"type":"string"},"code":{"type":"string","enum":["invalid_api_key","insufficient_scope","app_not_approved","owner_inactive","domain_unverified","app_on_hold","invalid_request","invalid_recipient","invalid_idempotency_key","unknown_category","idempotency_conflict","rate_limited","payload_too_large","unsupported_media_type","invalid_json","internal_error"]},"field":{"type":"string"}}}},"test":{"type":"boolean","const":true,"description":"Present when a test key was used."}}},"Error":{"type":"object","additionalProperties":false,"required":["error","code","requestId"],"properties":{"error":{"type":"string","description":"Human-readable explanation; do not match on this text."},"code":{"type":"string","enum":["invalid_api_key","insufficient_scope","app_not_approved","owner_inactive","domain_unverified","app_on_hold","invalid_request","invalid_recipient","invalid_idempotency_key","unknown_category","idempotency_conflict","rate_limited","payload_too_large","unsupported_media_type","invalid_json","internal_error"]},"requestId":{"type":"string"},"field":{"type":"string"}}}}}}