Events that failed all retry attempts are stored as dead letters for inspection and manual resolution. Dead letters come from two sources: the legacy record-index path (happyview_dead_letter_hooks) and trigger-keyed scripts (happyview_dead_letter_scripts). Both tables are surfaced through a single unified API.
Resolved script dead letters are deleted once they have been resolved for DEAD_LETTER_RETENTION_DAYS days (30 by default; see Configuration). Unresolved ones are kept until someone deals with them.
Read endpoints require dead-letters:read. Action endpoints (dismiss, retry, reindex) require dead-letters:manage.
const TOKEN = "hv_..."; // your API keyconst headers = { Authorization: `Bearer ${TOKEN}` };const TOKEN = "hv_..."; // your API keyconst headers = { Authorization: `Bearer ${TOKEN}` };let token = "hv_..."; // your API keytoken := "hv_..." // your API key# All examples assume $TOKEN is an API key (hv_...)AUTH="Authorization: Bearer $TOKEN"List dead letters
Section titled “List dead letters”GET /admin/dead-lettersPaginated list of dead letters from both tables, merged and sorted by created_at descending.
| Param | Type | Required | Description |
|---|---|---|---|
collection | string | no | Filter by collection NSID |
resolved | string | no | "false" (default, unresolved only), "true" (resolved only), or any other value such as "all" for both |
cursor | string | no | Pagination cursor from a previous response |
limit | number | no | Max results per page, 1 to 100 (default 50) |
const response = await fetch("http://127.0.0.1:3000/admin/dead-letters?limit=10", { headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/dead-letters?limit=10", { headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .get("http://127.0.0.1:3000/admin/dead-letters?limit=10") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/dead-letters?limit=10", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)curl "http://127.0.0.1:3000/admin/dead-letters?limit=10" -H "$AUTH"Response: 200 OK
{ "dead_letters": [ { "id": "42", "lexicon_id": "xyz.statusphere.status", "uri": "at://did:plc:abc/xyz.statusphere.status/3k...", "did": "did:plc:abc", "collection": "xyz.statusphere.status", "rkey": "3k...", "action": "create", "error": "script error: attempt to index nil value", "attempts": 4, "created_at": "2026-06-01T12:00:00Z" } ], "cursor": "2026-06-01T11:00:00+00:00"}Resolved dead letters include a resolved_at timestamp. cursor is the last row’s created_at, and is returned only when there are more rows.
For label rows, action is "label", lexicon_id is the script’s trigger id, did is the labeler’s DID and rkey is the label value.
Count dead letters
Section titled “Count dead letters”GET /admin/dead-letters/countReturns the total count of dead letters across both tables.
| Param | Type | Required | Description |
|---|---|---|---|
collection | string | no | Filter by collection NSID |
resolved | string | no | "false" (default, unresolved only), "true" (resolved only), or any other value such as "all" for both |
const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/count", { headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/count", { headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .get("http://127.0.0.1:3000/admin/dead-letters/count") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/dead-letters/count", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)curl "http://127.0.0.1:3000/admin/dead-letters/count" -H "$AUTH"Response: 200 OK
{ "count": 7}Get dead letter detail
Section titled “Get dead letter detail”GET /admin/dead-letters/{id}Returns the full dead letter including the record body.
const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/42", { headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/42", { headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .get("http://127.0.0.1:3000/admin/dead-letters/42") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/dead-letters/42", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)curl "http://127.0.0.1:3000/admin/dead-letters/42" -H "$AUTH"Response: 200 OK
The response includes all fields from the list view plus a record field containing the original record data (if available). Returns 404 if the dead letter is not found.
Dismiss a dead letter
Section titled “Dismiss a dead letter”POST /admin/dead-letters/{id}/dismissMarks a dead letter as resolved without retrying it.
const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/42/dismiss", { method: "POST", headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/42/dismiss", { method: "POST", headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/dead-letters/42/dismiss") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/dead-letters/42/dismiss", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)curl -X POST "http://127.0.0.1:3000/admin/dead-letters/42/dismiss" -H "$AUTH"Response: 200 OK with {"ok": true}
Returns 404 if the dead letter is not found.
Retry a dead letter
Section titled “Retry a dead letter”POST /admin/dead-letters/{id}/retryRe-runs the dead letter’s script with the original event payload. On success the dead letter is marked resolved. On failure the error and attempt count are updated, and the request returns 500 Internal Server Error. A dead letter with an unknown action returns 400 Bad Request.
Label-arrival dead letters can’t be retried, because the upstream label event is gone. These return 400 Bad Request.
const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/42/retry", { method: "POST", headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/42/retry", { method: "POST", headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/dead-letters/42/retry") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/dead-letters/42/retry", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)curl -X POST "http://127.0.0.1:3000/admin/dead-letters/42/retry" -H "$AUTH"Response: 200 OK with {"ok": true}
Returns 404 if no matching script binding is found, or if the dead letter is not found or already resolved.
Reindex a dead letter
Section titled “Reindex a dead letter”POST /admin/dead-letters/{id}/reindexFetches the record fresh from the author’s PDS and re-runs the full record indexing pipeline. On success the dead letter is marked resolved. Label-arrival dead letters cannot be reindexed and return 400 Bad Request.
const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/42/reindex", { method: "POST", headers,});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/42/reindex", { method: "POST", headers,});const data = await response.json();let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/dead-letters/42/reindex") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/dead-letters/42/reindex", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)curl -X POST "http://127.0.0.1:3000/admin/dead-letters/42/reindex" -H "$AUTH"Response: 200 OK with {"ok": true}
Bulk dismiss
Section titled “Bulk dismiss”POST /admin/dead-letters/bulk/dismissDismiss multiple dead letters at once.
| Field | Type | Required | Description |
|---|---|---|---|
ids | string[] | no | List of dead letter IDs to dismiss |
all | boolean | no | Set to true to dismiss all unresolved dead letters |
collection | string | no | When all is true, limit to this collection |
One of ids or all: true is required.
const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/bulk/dismiss", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ ids: [ "42", "43", "44" ] }),});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/bulk/dismiss", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ ids: [ "42", "43", "44" ] }),});const data = await response.json();let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/dead-letters/bulk/dismiss") .bearer_auth(token) .json(&serde_json::json!({ "ids": [ "42", "43", "44" ] })) .send() .await?;let data: serde_json::Value = response.json().await?;body := bytes.NewBufferString(`{ "ids": [ "42", "43", "44" ]}`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/dead-letters/bulk/dismiss", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)curl -X POST "http://127.0.0.1:3000/admin/dead-letters/bulk/dismiss" \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{"ids": ["42", "43", "44"]}'Response: 200 OK with {"ok": true}
Bulk retry
Section titled “Bulk retry”POST /admin/dead-letters/bulk/retryRetry multiple dead letters. Accepts the same input as bulk dismiss.
const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/bulk/retry", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ all: true, collection: "xyz.statusphere.status" }),});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/bulk/retry", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ all: true, collection: "xyz.statusphere.status" }),});const data = await response.json();let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/dead-letters/bulk/retry") .bearer_auth(token) .json(&serde_json::json!({ "all": true, "collection": "xyz.statusphere.status" })) .send() .await?;let data: serde_json::Value = response.json().await?;body := bytes.NewBufferString(`{ "all": true, "collection": "xyz.statusphere.status"}`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/dead-letters/bulk/retry", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)curl -X POST "http://127.0.0.1:3000/admin/dead-letters/bulk/retry" \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{"all": true, "collection": "xyz.statusphere.status"}'Response: 200 OK, with one entry per dead letter in the order they were tried. A dead letter that fails doesn’t stop the rest. Its entry carries the error, and the top-level ok is false unless every one succeeded:
{ "ok": false, "succeeded": 2, "failed": 1, "results": [ { "id": "42", "ok": true }, { "id": "43", "ok": false, "error": "not found: dead letter 43 not found or already resolved" }, { "id": "44", "ok": true } ]}Bulk reindex
Section titled “Bulk reindex”POST /admin/dead-letters/bulk/reindexReindex multiple dead letters. Accepts the same input as bulk dismiss.
const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/bulk/reindex", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ all: true }),});const data = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/dead-letters/bulk/reindex", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ all: true }),});const data = await response.json();let client = reqwest::Client::new();let response = client .post("http://127.0.0.1:3000/admin/dead-letters/bulk/reindex") .bearer_auth(token) .json(&serde_json::json!({ "all": true })) .send() .await?;let data: serde_json::Value = response.json().await?;body := bytes.NewBufferString(`{ "all": true}`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/dead-letters/bulk/reindex", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)curl -X POST "http://127.0.0.1:3000/admin/dead-letters/bulk/reindex" \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{"all": true}'Response: 200 OK, in the same shape as bulk retry: one entry per dead letter, and a failure doesn’t stop the rest.
© HappyProto

