{"openapi":"3.1.0","info":{"title":"PeskyBird","version":"0.1.0-alpha.4","summary":"PeskyBird push protocol v1","description":"The part of the PeskyBird push API that scripts and servers use with an app token. Bodies and responses are JSON unless stated; clients must ignore unknown response fields.","license":{"name":"Apache-2.0","identifier":"Apache-2.0"}},"x-peskybird-protocol":1,"tags":[{"name":"publish","description":"Sending. App token."},{"name":"meta","description":"Discovery. Always exposed, unauthenticated."}],"paths":{"/topics/{topicName}":{"post":{"operationId":"publishToTopic","tags":["publish"],"summary":"Publish to the topic named in the path","description":"Also accepts a `text/plain` or `application/x-www-form-urlencoded` body (what `curl -d` sends), taken verbatim as `body`, neither parsed nor URL-decoded. On such a request the `Title`, `Priority`, `Format`, `Image` and `Url` headers set those fields and are validated like them; on a JSON request they are ignored. Anything else needs JSON.","security":[{"appToken":[]}],"parameters":[{"name":"topicName","in":"path","required":true,"description":"Topic name, unique per server: lowercase letters and digits in groups joined by single hyphens (like `home-automation`), 1 to 32 characters.","schema":{"$ref":"#/components/schemas/TopicName"}},{"name":"Title","in":"header","required":false,"description":"Sets `title`.","schema":{"description":"Sets `title`.","type":"string","maxLength":256}},{"name":"Priority","in":"header","required":false,"description":"Sets `priority`: `low`, `normal`, `high`, `critical`.","schema":{"description":"Sets `priority`: `low`, `normal`, `high`, `critical`.","$ref":"#/components/schemas/Priority"}},{"name":"Format","in":"header","required":false,"description":"Sets `format`: `text` or `markdown`.","schema":{"description":"Sets `format`: `text` or `markdown`.","$ref":"#/components/schemas/MessageFormat"}},{"name":"Image","in":"header","required":false,"description":"Sets `image`, an https URL.","schema":{"description":"Sets `image`, an https URL.","type":"string","maxLength":2048,"format":"uri"}},{"name":"Url","in":"header","required":false,"description":"Sets `url`, opened from the message.","schema":{"description":"Sets `url`, opened from the message.","type":"string","maxLength":2048,"format":"uri"}},{"name":"Replaces","in":"header","required":false,"description":"Sets `replaces`.","schema":{"description":"Sets `replaces`.","$ref":"#/components/schemas/ReplacesKey"}},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","minLength":1,"maxLength":255,"description":"Any string up to 255 characters, unique per send (a UUID, say). Sending again with the same key within 24 hours returns the first send's result instead of sending twice. The same key with a different request, or while the first send is still running, is refused with `409 conflict`."}},{"name":"dryRun","in":"query","required":false,"description":"`true` checks the send without making it: the body, the token's topics and the topic are validated and `queued` counts the devices it would reach, but nothing is stored, sent, created or counted toward limits.","schema":{"description":"`true` checks the send without making it: the body, the token's topics and the topic are validated and `queued` counts the devices it would reach, but nothing is stored, sent, created or counted toward limits.","type":"string","enum":["true","false"]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TopicSendInput"}},"text/plain":{"schema":{"type":"string"}},"application/x-www-form-urlencoded":{"schema":{"type":"string"}}}},"responses":{"201":{"description":"Stored and fanned out (or queued).","headers":{"RateLimit-Policy":{"description":"The quota policy, for example `\"daily\";q=500;w=86400`: `q` messages per window of `w` seconds.","schema":{"type":"string","description":"The quota policy, for example `\"daily\";q=500;w=86400`: `q` messages per window of `w` seconds."}},"RateLimit":{"description":"What is left of that policy, for example `\"daily\";r=487;t=40213`: `r` messages remaining (after this one) and `t` seconds until the count resets.","schema":{"type":"string","description":"What is left of that policy, for example `\"daily\";r=487;t=40213`: `r` messages remaining (after this one) and `t` seconds until the count resets."}},"Idempotent-Replayed":{"description":"Present when an `Idempotency-Key` matched an earlier send: the body is that send's result and nothing was sent again.","schema":{"type":"string","const":"true","description":"Present when an `Idempotency-Key` matched an earlier send: the body is that send's result and nothing was sent again."}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"}}}},"400":{"description":"Error; `error.code` is one of `invalid_request`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Error; `error.code` is one of `unauthorized`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Error; `error.code` is one of `forbidden`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"409":{"description":"Error; `error.code` is one of `conflict`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"413":{"description":"Error; `error.code` is one of `payload_too_large`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Error; `error.code` is one of `rate_limited`.","headers":{"RateLimit-Policy":{"description":"The quota policy, for example `\"daily\";q=500;w=86400`: `q` messages per window of `w` seconds.","schema":{"type":"string","description":"The quota policy, for example `\"daily\";q=500;w=86400`: `q` messages per window of `w` seconds."}},"RateLimit":{"description":"What is left of that policy, for example `\"daily\";r=487;t=40213`: `r` messages remaining (after this one) and `t` seconds until the count resets.","schema":{"type":"string","description":"What is left of that policy, for example `\"daily\";r=487;t=40213`: `r` messages remaining (after this one) and `t` seconds until the count resets."}},"Retry-After":{"description":"Seconds to wait before sending again.","schema":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Seconds to wait before sending again."}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"500":{"description":"Error; `error.code` is one of `internal`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"default":{"description":"Any other error, same envelope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/messages":{"post":{"operationId":"publish","tags":["publish"],"summary":"Publish a message to a topic","description":"`payload_too_large` is returned before parsing when the body exceeds `maxBodyBytes`; `invalid_request` when a field exceeds its own limit; `forbidden` when the topic is outside the token's `topicScope`.","security":[{"appToken":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","minLength":1,"maxLength":255,"description":"Any string up to 255 characters, unique per send (a UUID, say). Sending again with the same key within 24 hours returns the first send's result instead of sending twice. The same key with a different request, or while the first send is still running, is refused with `409 conflict`."}},{"name":"dryRun","in":"query","required":false,"description":"`true` checks the send without making it: the body, the token's topics and the topic are validated and `queued` counts the devices it would reach, but nothing is stored, sent, created or counted toward limits.","schema":{"description":"`true` checks the send without making it: the body, the token's topics and the topic are validated and `queued` counts the devices it would reach, but nothing is stored, sent, created or counted toward limits.","type":"string","enum":["true","false"]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendInput"}}}},"responses":{"201":{"description":"Stored and fanned out (or queued).","headers":{"RateLimit-Policy":{"description":"The quota policy, for example `\"daily\";q=500;w=86400`: `q` messages per window of `w` seconds.","schema":{"type":"string","description":"The quota policy, for example `\"daily\";q=500;w=86400`: `q` messages per window of `w` seconds."}},"RateLimit":{"description":"What is left of that policy, for example `\"daily\";r=487;t=40213`: `r` messages remaining (after this one) and `t` seconds until the count resets.","schema":{"type":"string","description":"What is left of that policy, for example `\"daily\";r=487;t=40213`: `r` messages remaining (after this one) and `t` seconds until the count resets."}},"Idempotent-Replayed":{"description":"Present when an `Idempotency-Key` matched an earlier send: the body is that send's result and nothing was sent again.","schema":{"type":"string","const":"true","description":"Present when an `Idempotency-Key` matched an earlier send: the body is that send's result and nothing was sent again."}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"}}}},"400":{"description":"Error; `error.code` is one of `invalid_request`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Error; `error.code` is one of `unauthorized`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Error; `error.code` is one of `forbidden`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"409":{"description":"Error; `error.code` is one of `conflict`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"413":{"description":"Error; `error.code` is one of `payload_too_large`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Error; `error.code` is one of `rate_limited`.","headers":{"RateLimit-Policy":{"description":"The quota policy, for example `\"daily\";q=500;w=86400`: `q` messages per window of `w` seconds.","schema":{"type":"string","description":"The quota policy, for example `\"daily\";q=500;w=86400`: `q` messages per window of `w` seconds."}},"RateLimit":{"description":"What is left of that policy, for example `\"daily\";r=487;t=40213`: `r` messages remaining (after this one) and `t` seconds until the count resets.","schema":{"type":"string","description":"What is left of that policy, for example `\"daily\";r=487;t=40213`: `r` messages remaining (after this one) and `t` seconds until the count resets."}},"Retry-After":{"description":"Seconds to wait before sending again.","schema":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Seconds to wait before sending again."}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"500":{"description":"Error; `error.code` is one of `internal`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"default":{"description":"Any other error, same envelope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/sends/{id}":{"get":{"operationId":"getSend","tags":["publish"],"summary":"A sent message and how many devices it reached","description":"For a message on a topic the token may send to. With deferred fan-out, poll until `complete`.","security":[{"appToken":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"ULID: 26 Crockford base32 characters, lexically sortable. Message ids double as the sync cursor.","schema":{"$ref":"#/components/schemas/Ulid"}}],"responses":{"200":{"description":"The message and its counts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendStatus"}}}},"401":{"description":"Error; `error.code` is one of `unauthorized`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Error; `error.code` is one of `forbidden`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Error; `error.code` is one of `not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Error; `error.code` is one of `rate_limited`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"500":{"description":"Error; `error.code` is one of `internal`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"default":{"description":"Any other error, same envelope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/token":{"get":{"operationId":"getToken","tags":["publish"],"summary":"The app token making the request","description":"Checks a token without sending: its name, the topics it may send to and its account.","security":[{"appToken":[]}],"responses":{"200":{"description":"The token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenInfo"}}}},"401":{"description":"Error; `error.code` is one of `unauthorized`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Error; `error.code` is one of `forbidden`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Error; `error.code` is one of `rate_limited`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"500":{"description":"Error; `error.code` is one of `internal`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"default":{"description":"Any other error, same envelope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/topics":{"get":{"operationId":"listTokenTopics","tags":["publish"],"summary":"The topics this token may send to, with how many devices receive each","description":"Every topic for a token without a scope; the scope's names for one with a scope, including names with no topic yet.","security":[{"appToken":[]}],"responses":{"200":{"description":"The topics, by name.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenTopicList"}}}},"401":{"description":"Error; `error.code` is one of `unauthorized`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Error; `error.code` is one of `forbidden`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Error; `error.code` is one of `rate_limited`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"500":{"description":"Error; `error.code` is one of `internal`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"default":{"description":"Any other error, same envelope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/limits":{"get":{"operationId":"getLimits","tags":["publish"],"summary":"The limits on this account's sends and how much is used","security":[{"appToken":[]}],"responses":{"200":{"description":"Limits and usage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountLimits"}}}},"401":{"description":"Error; `error.code` is one of `unauthorized`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Error; `error.code` is one of `forbidden`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Error; `error.code` is one of `rate_limited`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"500":{"description":"Error; `error.code` is one of `internal`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"default":{"description":"Any other error, same envelope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/devices":{"get":{"operationId":"listTokenDevices","tags":["publish"],"summary":"The paired devices, for sending to some of them","description":"Revoked devices are left out. A send's `devices` takes these ids; a device still has to receive the topic.","security":[{"appToken":[]}],"responses":{"200":{"description":"The devices.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountDeviceList"}}}},"401":{"description":"Error; `error.code` is one of `unauthorized`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Error; `error.code` is one of `forbidden`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Error; `error.code` is one of `rate_limited`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"500":{"description":"Error; `error.code` is one of `internal`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"default":{"description":"Any other error, same envelope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/.well-known/peskybird":{"get":{"operationId":"wellKnown","tags":["meta"],"summary":"Server identity, protocol range and exposed groups","security":[],"responses":{"200":{"description":"Server descriptor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WellKnown"}}}},"429":{"description":"Error; `error.code` is one of `rate_limited`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"500":{"description":"Error; `error.code` is one of `internal`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"default":{"description":"Any other error, same envelope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}}},"components":{"securitySchemes":{"appToken":{"type":"http","scheme":"bearer","bearerFormat":"pba_<base64url 32 bytes> or pba_<accountId>.<base64url 32 bytes>","description":"An app token from the dashboard's App tokens page."}},"schemas":{"Account":{"type":"object","properties":{"id":{"description":"Account id; part of the routing hash on multi-tenant servers.","$ref":"#/components/schemas/Ulid"},"name":{"type":"string","minLength":1,"maxLength":128,"description":"Account display name, shown in the app. The account owner may rename it."}},"required":["id","name"],"description":"The account a device belongs to on a multi-tenant server."},"AccountDevice":{"type":"object","properties":{"id":{"description":"Use it in a send's `devices` to reach only this device.","$ref":"#/components/schemas/Ulid"},"name":{"type":"string","minLength":1,"maxLength":64,"description":"User-visible device name, 1 to 64 chars."},"platform":{"$ref":"#/components/schemas/Platform"},"status":{"type":"string","enum":["pending","active"],"description":"`pending` until the device first reports its push token."},"topics":{"type":"array","items":{"$ref":"#/components/schemas/TopicName"},"description":"Topics the device receives (subscribed and not muted)."}},"required":["id","name","platform","status","topics"],"description":"A paired device, as an app token sees it."},"AccountDeviceList":{"type":"object","properties":{"devices":{"type":"array","items":{"$ref":"#/components/schemas/AccountDevice"},"description":"Paired devices that are not revoked."}},"required":["devices"]},"AccountLimits":{"type":"object","properties":{"messagesPerDay":{"description":"Messages accepted today. Present when the server limits daily sends.","$ref":"#/components/schemas/LimitUsage"},"topics":{"description":"Topics that exist. Present when the server limits topics.","$ref":"#/components/schemas/LimitUsage"},"devices":{"description":"Paired devices, pending or active. Present when the server limits devices.","$ref":"#/components/schemas/LimitUsage"},"requestsPerSecond":{"description":"Burst rate per app token. Present when the server states one.","type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"imagesEnabled":{"description":"Whether messages may carry an `image`. Absent means the server does not restrict it.","type":"boolean"},"maxBodyBytes":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991,"description":"Largest request body, in bytes."},"bodyMaxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991,"description":"Longest message `body`, in characters."},"retentionDays":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991,"description":"How long messages are kept, in days."}},"required":["maxBodyBytes","bodyMaxChars","retentionDays"],"description":"The limits that apply to this token's sends. The same numbers as the usage headers; reading them counts toward no limit except the burst rate."},"ApiGroup":{"type":"string","enum":["pairing","device","sync","publish","admin"],"description":"An API group a host can expose. `meta` is always exposed and is therefore not listed. Closed enum."},"ErrorCode":{"anyOf":[{"type":"string","enum":["invalid_request","pairing_invalid","pairing_expired","unauthorized","forbidden","not_found","payload_too_large","rate_limited","internal","conflict"]},{"type":"string","description":"Any value not listed; added after this spec was generated."}],"description":"Machine-readable error code. Clients switch on this, not on the HTTP status. Open enum: new codes may be added without a protocol bump; treat an unknown code by its HTTP status."},"ErrorEnvelope":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"$ref":"#/components/schemas/ErrorCode"},"message":{"type":"string","description":"Human-readable, English, for logs and developers, not for end users."},"details":{"description":"Code-specific. `unauthorized` carries `{ \"reason\": \"revoked\" }` when the device credential was revoked. `forbidden` may carry `{ \"reason\": \"account_suspended\" }` (the account behind the credential is suspended) or a host-defined reason such as `device_limit`, `topic_limit` or `images_not_enabled`, often with `max`; `rate_limited` may carry `{ \"limit\", \"max\", \"resetAt\" }`."}},"required":["code","message"],"description":"What went wrong."}},"required":["error"],"description":"The single error shape of every non-2xx response."},"LimitUsage":{"type":"object","properties":{"limit":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"The most allowed."},"used":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"How much is used now."},"resetAt":{"description":"When `used` goes back to 0, for a limit that resets.","type":"string","format":"date-time"}},"required":["limit","used"]},"Message":{"type":"object","properties":{"id":{"description":"Message id; also the sync cursor.","$ref":"#/components/schemas/Ulid"},"topicId":{"$ref":"#/components/schemas/Ulid"},"topicName":{"$ref":"#/components/schemas/TopicName"},"title":{"description":"Shown above the body.","type":"string","maxLength":256},"body":{"type":"string","minLength":1,"maxLength":2048,"description":"The message text; Markdown when `format` is `markdown`."},"priority":{"$ref":"#/components/schemas/Priority"},"url":{"description":"Opened on tap.","type":"string","maxLength":2048,"format":"uri"},"actions":{"description":"Buttons shown with the notification.","maxItems":4,"type":"array","items":{"$ref":"#/components/schemas/MessageAction"}},"data":{"$ref":"#/components/schemas/MessageData"},"createdAt":{"type":"string","format":"date-time","description":"RFC 3339 / ISO 8601 timestamp with offset; UTC recommended."},"expiresAt":{"type":"string","format":"date-time","description":"RFC 3339 / ISO 8601 timestamp with offset; UTC recommended."},"format":{"$ref":"#/components/schemas/MessageFormat"},"image":{"type":"string","maxLength":2048,"format":"uri","description":"HTTPS URL of an image the phone downloads itself, at most 2048 chars. The server stores the URL and never fetches it."},"sentByTokenId":{"description":"Id of the app token that published the message; absent for server-originated messages.","$ref":"#/components/schemas/Ulid"},"replaces":{"description":"Set by the sender: this message takes the place of earlier messages on the same topic with the same key.","$ref":"#/components/schemas/ReplacesKey"}},"required":["id","topicId","topicName","body","priority","createdAt"],"description":"A stored notification. `id` is the sync cursor."},"MessageAction":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":32,"description":"Stable action identifier chosen by the publisher."},"title":{"type":"string","minLength":1,"maxLength":64,"description":"Button label."},"url":{"description":"Opened when the action is tapped.","type":"string","maxLength":2048,"format":"uri"}},"required":["id","title"],"description":"A button shown with the notification."},"MessageData":{"type":"object","propertyNames":{"type":"string","minLength":1,"maxLength":64},"additionalProperties":{"type":"string","maxLength":512},"maxProperties":16,"description":"Opaque string map passed through to the client. At most 16 keys and 1024 bytes serialized. Keys starting with `_` are reserved for the server and rejected on input."},"MessageFormat":{"type":"string","enum":["text","markdown"],"description":"How `body` is rendered. `markdown` is a subset of CommonMark; the notification shows its plain text. Absent means `text`. Closed enum."},"Platform":{"type":"string","enum":["ios","android"],"description":"Client platform. Closed enum."},"Priority":{"type":"string","enum":["low","normal","high","critical"],"description":"Platform-neutral priority; each transport maps it to its own levels. Closed enum."},"ProtocolRange":{"type":"object","properties":{"min":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991,"description":"Oldest protocol version served."},"max":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991,"description":"Newest protocol version served."}},"required":["min","max"],"description":"Inclusive range of protocol versions."},"ReplacesKey":{"type":"string","minLength":1,"maxLength":32,"pattern":"^[A-Za-z0-9._:-]+$","description":"A key you choose, 1 to 32 letters, digits, `.`, `_`, `:` or `-`. A later message on the same topic with the same key takes the earlier one's place on the phone instead of adding a notification (`disk-nas-01`: 91%, then 95%, then resolved)."},"SendInput":{"type":"object","properties":{"topic":{"description":"Created on first use when the server auto-creates topics.","$ref":"#/components/schemas/TopicName"},"title":{"description":"Shown above the body. At most 256 characters.","type":"string","maxLength":256},"body":{"type":"string","minLength":1,"maxLength":2048,"description":"The message text, required. At most 2048 characters; Markdown when `format` is `markdown`."},"priority":{"default":"normal","description":"`low`, `normal` (default), `high` or `critical`. Each transport maps it to its own levels.","$ref":"#/components/schemas/Priority"},"url":{"description":"Opened on tap.","type":"string","maxLength":2048,"format":"uri"},"actions":{"description":"Up to 4 buttons shown with the notification.","maxItems":4,"type":"array","items":{"$ref":"#/components/schemas/MessageAction"}},"data":{"description":"String key-value pairs delivered with the message for the receiving app; at most 16 keys and 1024 bytes serialized. Keys starting with `_` are reserved.","$ref":"#/components/schemas/MessageData"},"format":{"description":"How `body` is rendered. Default `text`. A `markdown` body is limited to the same 2048 characters.","$ref":"#/components/schemas/MessageFormat"},"image":{"type":"string","maxLength":2048,"format":"uri","description":"HTTPS URL of an image the phone downloads itself, at most 2048 chars. The server stores the URL and never fetches it."},"expiresInMs":{"description":"Relative TTL in milliseconds, at most 30 days; the server stores `expiresAt`.","type":"integer","exclusiveMinimum":0,"maximum":2592000000},"devices":{"description":"Restrict fan-out to these device ids; they must still be subscribed to the topic.","maxItems":200,"type":"array","items":{"$ref":"#/components/schemas/Ulid"}},"replaces":{"$ref":"#/components/schemas/ReplacesKey"}},"required":["topic","body"],"description":"Body of `POST /messages`."},"SendResult":{"type":"object","properties":{"messageId":{"description":"Id of the stored message. On a dry run, the id it would have had; nothing is stored.","$ref":"#/components/schemas/Ulid"},"queued":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Devices handed to the queue."},"sent":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Deliveries reported `sent` before the response; 0 when fan-out is deferred."},"failed":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Deliveries that failed on the first attempt; 0 when fan-out is deferred."},"dryRun":{"description":"Present on a dry run: the send was checked and `queued` counts the devices it would reach, but nothing was stored, sent or counted.","type":"boolean","const":true}},"required":["messageId","queued","sent","failed"],"description":"Response of a successful send."},"SendStatus":{"type":"object","properties":{"message":{"$ref":"#/components/schemas/Message"},"recipients":{"description":"Devices the message was handed to. Absent for messages sent before servers recorded it.","type":"integer","minimum":0,"maximum":9007199254740991},"sent":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Devices the push service accepted the message for."},"failed":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Devices it could not be delivered to."},"skipped":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Devices that were not tried: expired before delivery, or no transport for them."},"complete":{"type":"boolean","description":"True once every recipient has a result. A failed attempt may still be retried."}},"required":["message","sent","failed","skipped","complete"],"description":"A sent message and what happened to it, counted by device."},"TokenInfo":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Ulid"},"name":{"type":"string","minLength":1,"maxLength":64,"description":"App token name, 1 to 64 chars."},"suffix":{"type":"string","minLength":4,"maxLength":4,"description":"Last 4 characters of the token, for display only."},"topicScope":{"description":"Topic names this token may send to. Absent means every topic.","maxItems":64,"type":"array","items":{"$ref":"#/components/schemas/TopicName"}},"createdAt":{"type":"string","format":"date-time","description":"RFC 3339 / ISO 8601 timestamp with offset; UTC recommended."},"account":{"description":"The account the token sends for, on multi-tenant servers.","$ref":"#/components/schemas/Account"}},"required":["id","name","suffix","createdAt"],"description":"The app token making the request."},"TokenTopic":{"type":"object","properties":{"name":{"$ref":"#/components/schemas/TopicName"},"exists":{"type":"boolean","description":"False for a name in the token's scope that has no topic yet; sending to it creates the topic when the server auto-creates topics."},"subscribers":{"type":"integer","minimum":0,"maximum":9007199254740991,"description":"Active devices that receive messages on this topic (subscribed and not muted)."},"createdAt":{"type":"string","format":"date-time","description":"RFC 3339 / ISO 8601 timestamp with offset; UTC recommended."}},"required":["name","exists","subscribers"]},"TokenTopicList":{"type":"object","properties":{"autoCreate":{"type":"boolean","description":"Whether sending to a topic that does not exist yet creates it."},"topics":{"type":"array","items":{"$ref":"#/components/schemas/TokenTopic"},"description":"The topics this token may send to, by name."}},"required":["autoCreate","topics"]},"TopicName":{"type":"string","maxLength":32,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","description":"Topic name, unique per server: lowercase letters and digits in groups joined by single hyphens (like `home-automation`), 1 to 32 characters."},"TopicSendInput":{"type":"object","properties":{"title":{"description":"Shown above the body. At most 256 characters.","type":"string","maxLength":256},"body":{"type":"string","minLength":1,"maxLength":2048,"description":"The message text, required. At most 2048 characters; Markdown when `format` is `markdown`."},"priority":{"default":"normal","description":"`low`, `normal` (default), `high` or `critical`. Each transport maps it to its own levels.","$ref":"#/components/schemas/Priority"},"url":{"description":"Opened on tap.","type":"string","maxLength":2048,"format":"uri"},"actions":{"description":"Up to 4 buttons shown with the notification.","maxItems":4,"type":"array","items":{"$ref":"#/components/schemas/MessageAction"}},"data":{"description":"String key-value pairs delivered with the message for the receiving app; at most 16 keys and 1024 bytes serialized. Keys starting with `_` are reserved.","$ref":"#/components/schemas/MessageData"},"format":{"description":"How `body` is rendered. Default `text`. A `markdown` body is limited to the same 2048 characters.","$ref":"#/components/schemas/MessageFormat"},"image":{"type":"string","maxLength":2048,"format":"uri","description":"HTTPS URL of an image the phone downloads itself, at most 2048 chars. The server stores the URL and never fetches it."},"expiresInMs":{"description":"Relative TTL in milliseconds, at most 30 days; the server stores `expiresAt`.","type":"integer","exclusiveMinimum":0,"maximum":2592000000},"devices":{"description":"Restrict fan-out to these device ids; they must still be subscribed to the topic.","maxItems":200,"type":"array","items":{"$ref":"#/components/schemas/Ulid"}},"replaces":{"$ref":"#/components/schemas/ReplacesKey"}},"required":["body"],"description":"Body of `POST /topics/{topicName}`: `SendInput` without `topic`. A `text/plain` or `application/x-www-form-urlencoded` body is taken as `body`, verbatim; the `Title`, `Priority`, `Format`, `Image` and `Url` headers then set those fields."},"Ulid":{"type":"string","pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","description":"ULID: 26 Crockford base32 characters, lexically sortable. Message ids double as the sync cursor."},"WellKnown":{"type":"object","properties":{"name":{"type":"string","maxLength":128,"description":"Display name of the server."},"version":{"type":"string","maxLength":64,"description":"Server software version, for diagnostics."},"protocol":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991,"description":"Protocol version this server speaks by default. Clients store it per server profile."},"supportedProtocols":{"description":"Inclusive range of protocol versions the server can serve.","$ref":"#/components/schemas/ProtocolRange"},"groups":{"type":"array","items":{"$ref":"#/components/schemas/ApiGroup"},"description":"Groups the host exposed on this mount; `meta` is implied."},"time":{"description":"Server clock, so the app can detect gross skew.","type":"string","format":"date-time"},"multiTenant":{"description":"True on a server that hosts several accounts at one address: pairing then calls `POST /pair/preview` to show the account, and the app keeps one profile per account.","type":"boolean"}},"required":["name","version","protocol","supportedProtocols","groups"],"description":"Response of `GET /.well-known/peskybird`. The app calls it before pairing to validate a server URL."}}},"servers":[{"url":"https://peskybird.app/api/push"}]}