# Retrieve one delivery attempt **GET /delivery-attempts/{attempt}** One attempt from the delivery log, by its `id`. Needs `sending: read`. The same shape a page of them carries, and what `POST /delivery-attempts/{attempt}/replay` acts on. An attempt belonging to another newsletter answers `404`, the same as one that does not exist. The delivery log is kept per newsletter and an attempt is not a Commune object, so there is nothing in the path to work out which newsletter to look in: name it with `?newsletter=`. Leave it out if your credential reaches exactly one newsletter. **The body your endpoint answered with is not returned**, since a refusing server routinely echoes the request back inside it, credentials included. What came back is `response_status`, or `failure` when nothing answered at all; the full body is in the delivery portal. ## Servers - Production. There is no separate sandbox host. : https://api.usecommune.com (Production. There is no separate sandbox host. ) ## Authentication methods - Api key - Oauth2 ## Parameters ### Headers - **Commune-Version** (string) The contract version this request is written against. Every version published so far is a release date (`YYYY-MM-DD`), which is why the examples look like one, but the value is an opaque identifier: match it against the versions this API publishes rather than parsing it, because a future one may not be only a date. An unknown value answers `400` with `invalid_version`. Omitting the header pins the request to the version that was current when the API key was issued, so an integration keeps working when a newer version ships. ### Path parameters - **attempt** (string) The delivery attempt's `id`, as `GET /newsletters/{newsletter}/delivery-attempts` returned it. Opaque, and minted by the delivery service rather than by Commune, so it is not a UUID and must not be parsed as one. ### Query parameters - **newsletter** (string) Which newsletter this request is for, by `id` or by `handle`. Most operations never need this. A credential reaches a list of newsletters, and an operation that acts on one of them normally works out which from the object in its path: an article, a thread, a subscriber and a key each belong to a newsletter, so naming one is naming the other. This parameter is for the operations whose subject is **not** a Commune object, where there is nothing to work it out from. Leave it out if your credential reaches exactly one newsletter, which is the usual case: it is that one. If your credential reaches several and you leave it out, the answer is `400` naming this parameter, because picking one for you would be picking wrong most of the time. `GET /newsletters` lists the newsletters your credential reaches, and is where the value for this comes from. - **expand** (string) Comma-separated list of relationship paths to inline in the response. Unexpanded relationships are returned as a reference object carrying only `id` and `object`. Each operation documents the paths it accepts, and an unknown path answers `400`. Nested paths use a dot, for example `article.newsletter`. - **fields** (string) Comma-separated allow-list of top level properties to return on each object, so a client can trim a response it does not need in full. `id` and `object` are always returned. An unknown property name answers `400`. Properties omitted by an operation, such as `content` on any article list, cannot be brought back with `fields`. ## Responses ### 200 The delivery attempt. #### Body: application/json (object) - **object** (string) Always `delivery_attempt`. - **id** (string) The delivery service's identifier for this attempt. Opaque, and not a UUID: it is minted on the other side of the handover. - **newsletter** (object | null) The newsletter whose event this was. A `Ref` unless `newsletter` is named in `?expand=`. - **destination** (object | null) Where this was delivered. Always a `Ref`, whose `id` matches a row from `GET /newsletters/{newsletter}/destinations`. A destination deleted since the attempt was made still appears here, because the attempt happened; it will not be in that list any more. - **destination_type** (string) What kind of target it was, as the delivery service named it at the time. Free text for the same reason `Destination.type` is: the vocabulary belongs to the delivery service and grows there. - **event_id** (string) The event that was being delivered, by the `id` on its envelope. The same string the consumer receives in the `Commune-Event-Id` header, which makes it the one identifier both sides share and the thing worth logging on yours. - **event_type** (string | null) The topic, matching the keys of the `webhooks` block of this document. Null only if the delivery service no longer holds the event this attempt belonged to. - **status** (string) How this attempt ended. - **response_status** (integer | null) The HTTP status the destination answered with. Null when it did not answer at all, in which case `failure` says why. - **failure** (string | null) Why there was no answer, when there was none: `timeout` is the common one. Null whenever `response_status` is set, and the two are never both set or both null. Free text, so treat an unrecognised value as a reason this client does not know how to describe. - **attempt** (integer) 1 on the first delivery of this event to this destination, and one higher on each retry of it. The number the `Commune-Delivery-Attempt` header would carry if it were sent. - **manual** (boolean) Whether somebody asked for this attempt rather than the delivery service making it on its own. True for one made by `POST /delivery-attempts/{attempt}/replay` or by the retry button in the portal, and false for a first delivery or an automatic retry. - **created_at** (string(date-time)) When the attempt was made. ### 400 The request was malformed, and the same request will fail the same way until it is changed. `param` names the parameter or header at fault when there is exactly one, and `allowed_values` lists what it accepts when that is a finite set. The code is `bad_request` for every case below except the last. * **A query parameter**: one the operation does not have, a value outside its set, range or format (an unparseable cursor, an unknown `expand` path or `fields` name, an identifier that is not a UUID), or a required one left out, such as `q` on a search or `newsletter` when the credential reaches more than one. * **The request body**: not JSON, not the shape the operation reads, a property it does not write, or a value of the wrong type, length or format. `param` is absent here, since the body is not a parameter, and the message names the property. * **The `Idempotency-Key` header**, on an operation that changes something: missing, or a value this API will not store. * **An unrecognised `Commune-Version`**, which answers with its own code, `invalid_version`, because it is never fixed by changing the body. #### Body: application/json (object) - **error** (object) ### 401 No credential was presented, or it is malformed, unknown, revoked or expired, or it is an access token minted for a different audience. Every one of these answers identically, down to the wording and the headers, so a refusal never confirms that a string was once real. #### Headers - **WWW-Authenticate** (string) The authentication scheme this API accepts, and where to find out how to get a credential for it. Always `Bearer realm="Commune API", resource_metadata="https://api.usecommune.com/.well-known/oauth-protected-resource"`. `resource_metadata` is the RFC 9728 pointer to this API's protected resource metadata, which names the authorization server an OAuth client should send its user to. A client holding an API key can ignore it. The header carries no `error` parameter, not even `error="invalid_token"`, because it describes what this API accepts rather than what was wrong with the credential sent, and the reasons above are deliberately indistinguishable. There is no second scheme and no query-parameter fallback, because a credential that can travel in a URL ends up in access logs and referer headers. #### Body: application/json (object) - **error** (object) ### 403 The credential is valid but is not allowed to do this. Two codes answer with this status, and `error.code` says which. **`insufficient_scope`: it does not hold the permission.** The operation needs, say, `audience: read` on the newsletter addressed, and this credential holds less than that there. `allowed_values` carries the permission that was needed, and the message says what the credential does hold on that newsletter, because a credential granted the wrong family and a credential belonging to somebody whose standing on the team has narrowed look identical without it. The answer can differ per newsletter: the same credential may be allowed here and refused on the next one it reaches. The same code answers an operation that needs the **account permission** from a credential that does not carry it. That permission is about the person a credential belongs to rather than about any newsletter, so nothing granted on a newsletter adds up to it. It is granted on the credential itself, when a key is minted or when an authorization asks for `account:read`. And it answers a parameter the credential may send, but not with the value it sent: a filter a credential holding only `read` permissions may not use, or an `expand` path whose rows need a permission the operation does not. `param` names the parameter, and `allowed_values` carries what this credential may send instead, or is absent when it may send nothing there at all. **`forbidden`: it may not act here at all.** Either the credential does not reach the newsletter addressed, because it was never granted it or because the person it belongs to can no longer act on it, or it reaches no newsletter at all; `param` is `newsletter`, and `GET /newsletters` lists the ones it does reach. Or, on `DELETE /api-keys/{key}`, the credential named belongs to somebody else. Neither carries `allowed_values`, because there is no value to send instead. #### Body: application/json (object) - **error** (object) ### 404 No such resource, or the key is not allowed to know that it exists. Commune answers `404` rather than `403` where distinguishing the two would leak the existence of private content. #### Body: application/json (object) - **error** (object) ### 429 Too many requests. Back off and retry after the interval named by the `Retry-After` response header. One of the budgets in `RateLimit-Policy` ran out, and the `RateLimit-*` headers on this response say which and when it resets. #### Headers - **Retry-After** (integer) Seconds to wait before retrying. #### Body: application/json (object) - **error** (object) ### 500 Something failed inside Commune. The request may be retried. #### Body: application/json (object) - **error** (object) ### 503 A capability this operation depends on did not answer. Every other operation is unaffected, so back off on this one rather than on the API. Two parts of the API can answer this, because they are the only ones Commune cannot serve out of its own database. **Event delivery.** Destinations, the attempt log and the portal all live in the delivery service. It is never an empty answer instead, because a destination list or an attempt log that came back empty for this reason reads exactly like a newsletter that has registered no endpoints and sent nothing anywhere. **`sendArticleTest`.** A test copy is sent while the request is open, by Commune's sending service, and this answers when that service could not be reached or when the sending provider refused every address on the test, so nothing arrived. Nothing about the article changes either way, and the message says which of the two happened. #### Headers - **Retry-After** (integer) Seconds to wait before retrying. Absent in the one case that will not pass on its own, a deployment where event delivery is not available at all; the message says so, and retrying will not clear it. #### Body: application/json (object) - **error** (object) [Powered by Bump.sh](https://bump.sh)