Skip to main content

Triggers

14 operations under /v1/triggers (GET POST PATCH DELETE): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.

25 min read
View MarkdownEdit on GitHub

Base URL: https://api.codespar.dev

Every operation below requires a Bearer token. See Authentication.

Base URL: https://api.codespar.dev

See the Triggers concept for the mental model (event catalog, retry semantics, signature verification). This page is the endpoint reference.

All endpoints require authentication via Bearer token and operate on the resolved project. See Authentication.


POST /v1/triggers

Create a new trigger. The signing secret is returned once on this call and on rotate-secret, never in list/get responses.

Auth required: Yes

Request body

FieldTypeRequiredDescription
namestringYesFree-form label (shown in dashboard + delivery logs)
eventstringYesExact event name to subscribe to, e.g. commerce.payment.succeeded. Lowercase, dot-separated. Wildcards are not supported; * is rejected with 400.
webhook_urlstringYesHTTPS endpoint reachable from api.codespar.dev. Private, loopback, and cloud-metadata hosts are rejected.
server_idstringNoAssociate the trigger with one catalog server (must exist in the catalog). Used as a list filter; delivery matching is by event name.

curl example

curl -X POST https://api.codespar.dev/v1/triggers \
  -H "Authorization: Bearer csk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "fulfillment-pipeline",
    "event": "commerce.payment.succeeded",
    "webhook_url": "https://yourapp.com/api/webhooks/codespar",
    "server_id": "stripe"
  }'

Response -- 201 Created

{
  "id": "trg_a1b2c3d4e5f6g7h8",
  "org_id": "org_xyz789",
  "project_id": "prj_abc123",
  "name": "fulfillment-pipeline",
  "event": "commerce.payment.succeeded",
  "server_id": "stripe",
  "webhook_url": "https://yourapp.com/api/webhooks/codespar",
  "status": "active",
  "total_runs": 0,
  "last_run_at": null,
  "created_at": "2026-04-22T14:30:00Z",
  "signing_enabled": true,
  "secret": "8c2f4b0a1d9e6c3b5a7f0e2d4c6b8a9f1e3d5c7b9a0f2e4d6c8b0a1f3e5d7c9b"
}

The secret appears only on this response. Save it immediately to your secret manager (AWS Secrets Manager, Vercel Environment Variables, 1Password, etc.). If lost, use POST /v1/triggers/:id/rotate-secret to mint a new one, which invalidates the old.


GET /v1/triggers

List triggers in the resolved project, newest first, with cursor pagination.

Auth required: Yes

Query parameters

ParameterTypeDefaultDescription
limitnumber50Results per page (max 100)
beforestring--Cursor: a trigger id from the previous page (next_before)
statusstring--Filter by active, paused, or error
eventstring--Filter by exact event name
server_idstring--Filter by associated catalog server

Response -- 200 OK

{
  "triggers": [
    {
      "id": "trg_a1b2c3d4e5f6g7h8",
      "org_id": "org_xyz789",
      "project_id": "prj_abc123",
      "name": "fulfillment-pipeline",
      "event": "commerce.payment.succeeded",
      "server_id": "stripe",
      "webhook_url": "https://yourapp.com/api/webhooks/codespar",
      "status": "active",
      "total_runs": 1847,
      "last_run_at": "2026-04-22T14:30:00Z",
      "created_at": "2026-04-01T09:00:00Z",
      "signing_enabled": true
    }
  ],
  "next_before": null
}

Pass next_before back as before to fetch the next page; it is null on the last page. secret is never included in list responses.


GET /v1/triggers/:id

Get a single trigger's current state.

Auth required: Yes

Response -- 200 OK

Same shape as a single entry in the list response. No secret.


PATCH /v1/triggers/:id

Update name, webhook URL, or status. Any field omitted is left unchanged. The event filter is immutable: create a new trigger to subscribe to a different event.

Auth required: Yes

Request body

FieldTypeDescription
namestringNew label
webhook_urlstringNew HTTPS endpoint
statusstringactive or paused (error is read-only, set by the system on auto-pause)

Use case: resume an auto-paused trigger

curl -X PATCH https://api.codespar.dev/v1/triggers/trg_abc123 \
  -H "Authorization: Bearer csk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"status": "active"}'

DELETE /v1/triggers/:id

Hard-delete the trigger. New events stop firing immediately, and pending retries for the trigger are not re-dispatched (the retry worker only claims deliveries whose trigger still exists and is active).

Auth required: Yes

Response -- 204 No Content


POST /v1/triggers/:id/rotate-secret

Mint a new signing secret. The vault entry is overwritten in place, so the old secret stops signing new deliveries immediately; deliveries already signed with it may still arrive for a short window, so verify against the new secret and fall back to the old until traffic cuts over.

Auth required: Yes

Response -- 200 OK

{
  "trigger_id": "trg_abc123",
  "signing_enabled": true,
  "secret": "5d7c9b8c2f4b0a1d9e6c3b5a7f0e2d4c6b8a9f1e3d5c7b9a0f2e4d6c8b0a1f3e"
}

If the secret store is unavailable the endpoint returns 503 vault_unavailable and no rotation occurs.


POST /v1/triggers/:id/test-fire

Push a synthetic event through the real delivery pipeline: signing, retries, DLQ, everything operates exactly as in production, and the attempt shows up in the deliveries list. The trigger must be active. Only the named trigger receives the test fire; sibling triggers subscribed to the same event stay quiet.

Auth required: Yes

Request body (optional)

FieldTypeDescription
event_typestringEvent name to fire, defaults to trigger.test_fire. Set it to rehearse a specific handler, e.g. commerce.payment.succeeded.
payloadobjectExtra keys merged into the fixture { test: true, trigger_id, requested_at }

Response -- 202 Accepted

{
  "trigger_id": "trg_abc123",
  "event_id": "evt_9f8e7d6c5b4a3210",
  "delivery_id": "412",
  "status": "delivered",
  "response_status": 200,
  "error": null
}

status reflects the synchronous first attempt: delivered, or failed (a retry is scheduled on the normal backoff). Test-firing a trigger that is not active returns 409 trigger_not_active.


GET /v1/triggers/:id/deliveries

List recent delivery attempts for a trigger, successes and failures, newest first. Each attempt is its own row.

Auth required: Yes

Query parameters

ParameterTypeDescription
limitnumberDefault 50, max 200

Response -- 200 OK

{
  "deliveries": [
    {
      "id": "412",
      "trigger_id": "trg_abc123",
      "event_id": "evt_9f8e7d6c5b4a3210",
      "attempt": 1,
      "status": "delivered",
      "response_status": 200,
      "error": null,
      "delivered_at": "2026-04-22T14:30:01Z",
      "next_retry_at": null,
      "created_at": "2026-04-22T14:30:00Z"
    },
    {
      "id": "398",
      "trigger_id": "trg_abc123",
      "event_id": "evt_1a2b3c4d5e6f7890",
      "attempt": 5,
      "status": "dead",
      "response_status": 504,
      "error": "http_504",
      "delivered_at": null,
      "next_retry_at": null,
      "created_at": "2026-04-22T10:15:00Z"
    }
  ]
}

Delivery status values: delivered, failed (retry scheduled, see next_retry_at), pending (redelivery waiting for the retry worker), dead (all 5 attempts exhausted).


GET /v1/triggers/:id/deliveries/:did

Get a single delivery with the reconstructed request and captured response, useful for DLQ debugging.

Auth required: Yes

Response -- 200 OK

{
  "delivery": {
    "id": "412",
    "trigger_id": "trg_abc123",
    "event_id": "evt_9f8e7d6c5b4a3210",
    "attempt": 1,
    "status": "delivered",
    "response_status": 200,
    "error": null,
    "delivered_at": "2026-04-22T14:30:01Z",
    "next_retry_at": null,
    "dead_at": null,
    "receipt_at": null,
    "created_at": "2026-04-22T14:30:00Z"
  },
  "request": {
    "url": "https://yourapp.com/api/webhooks/codespar",
    "sent_at_unix": 1745332200,
    "headers": {
      "Content-Type": "application/json",
      "X-CodeSpar-Event": "commerce.payment.succeeded",
      "X-CodeSpar-Event-Id": "evt_9f8e7d6c5b4a3210",
      "X-CodeSpar-Trigger-Id": "trg_abc123",
      "X-CodeSpar-Attempt": "1",
      "X-CodeSpar-Signature": "t=1745332200,v1=<redacted>"
    },
    "body": "{\"id\":\"evt_9f8e7d6c5b4a3210\",\"type\":\"commerce.payment.succeeded\",\"source\":\"stripe\",\"occurred_at\":\"2026-04-22T14:30:00Z\",\"data\":{\"payment_id\":\"pay_123\",\"amount_minor\":14900}}"
  },
  "response": {
    "status": 200,
    "body": "",
    "error": null
  },
  "event": {
    "id": "evt_9f8e7d6c5b4a3210",
    "source": "stripe",
    "event_type": "commerce.payment.succeeded",
    "payload": { "payment_id": "pay_123", "amount_minor": 14900 },
    "received_at": "2026-04-22T14:30:00Z",
    "provider_event_id": "evt_native_123"
  }
}

The request.body is rebuilt with the same serializer used at dispatch time, byte-identical to what your endpoint received, so you can re-verify the HMAC locally using sent_at_unix and your signing secret. The v1 value itself is redacted because recomputing it requires the secret.


GET /v1/triggers/:id/dlq

Dead-lettered deliveries: attempts that exhausted all 5 tries without a 2xx. Ordered by dead_at descending. Accepts the same limit parameter as /deliveries (default 50, max 200).

Auth required: Yes

Response -- 200 OK

{
  "dead_letters": [
    {
      "id": "398",
      "event_id": "evt_1a2b3c4d5e6f7890",
      "attempt": 5,
      "response_status": 504,
      "error": "http_504",
      "dead_at": "2026-04-22T13:00:00Z",
      "created_at": "2026-04-22T12:59:50Z"
    }
  ]
}

POST /v1/triggers/deliveries/:did/redeliver

Operator-driven single redelivery. Inserts a fresh pending delivery for the same event (attempt count restarts at 1); the retry worker dispatches it on its next tick. Also resets the trigger's consecutive-failure streak. It does not reactivate an auto-paused trigger; use PATCH /v1/triggers/:id with status: "active" for that.

Auth required: Yes

Response -- 202 Accepted

{
  "redelivery_id": "431",
  "trigger_id": "trg_abc123",
  "event_id": "evt_1a2b3c4d5e6f7890",
  "trigger_status": "error"
}

The redelivery carries the same X-CodeSpar-Event-Id as the original event, so an idempotency check keyed on the event id still matches. The signature is recomputed with a fresh timestamp.


POST /v1/triggers/retry-pending

Project-scoped operation: re-dispatch every delivery whose retry is due, immediately, instead of waiting for the next retry-worker tick. Useful after fixing a widespread outage. Only active triggers are drained.

Auth required: Yes

Response -- 200 OK

{
  "scanned": 47,
  "redispatched": 47,
  "delivered": 45,
  "failed": 2,
  "dead": 0
}

Errors

Common status codes for this surface. See the full Error Reference for the complete list.

StatusError codeDescription
400invalid_body / invalid_queryMissing required field or malformed value; event names must be lowercase dot-separated (* and other wildcards are rejected)
400unknown_serverserver_id is not in the catalog
400not_https / private_host / reserved_hostwebhook_url is not HTTPS or points at a private, loopback, or cloud-metadata address
401unauthorizedInvalid or missing API key
404not_foundTrigger or delivery ID does not exist in the resolved project
409trigger_not_activeTest-fire on a paused or errored trigger
503vault_unavailableSecret rotation could not be persisted; no rotation occurred
500internal_errorRetry with exponential backoff

Next steps

Every operation, from the spec

Generated from the published OpenAPI document, so it never drifts from what the API actually serves. The section above is written by hand and carries what a schema cannot: the object model, field rules, and the order to call things in.

GET /v1/triggers

GEThttps://api.codespar.dev/v1/triggers
Deprecated

List triggers

DEPRECATED alias of GET /v1/webhook-endpoints (ent#979), kept for two releases. Same handler, same required scope; switch the path and nothing else changes.

Same cursor shape as /v1/sessions. The signing secret is never included.

Query parameters

NameTypeRequiredDescription
beforestringno—
eventstringno—
limitintegerno—
server_idstringno—
status"active" | "paused" | "error"no—

Responses

StatusBodyDescription
200objectOK
400objectBad Request — the body or query did not match the schema.

Response 200

FieldTypeRequiredDescription
next_beforestring,nullyes—
triggersarray of —yes—
Example request
curl -X GET https://api.codespar.dev/v1/triggers \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/triggers HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/triggers",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/triggers");
Example response 200
application/json
{
  "triggers": [],
  "next_before": "string"
}

POST /v1/triggers

POSThttps://api.codespar.dev/v1/triggers
Deprecated

Subscribe a webhook to an event

DEPRECATED alias of POST /v1/webhook-endpoints (ent#979), kept for two releases. Same handler, same required scope; switch the path and nothing else changes.

The response is the ONLY time the signing secret is returned in plaintext. webhook_url is refused when it names a loopback, private or metadata host; DNS is re-checked at dispatch, so passing here is not a promise it will resolve publicly later.

event is validated against a regex, NOT against a list of events the platform emits. A name that matches the shape but that nothing publishes is accepted with 201 and then never fires, with no signal to the subscriber.

Request body

FieldTypeRequiredDescription
actionobject | object | objectnoWhat the trigger does when it fires. Absent means webhook, the signed POST to webhook_url. human_review puts an item in the approvals inbox; pause_agent suspends the agent named by agent_did, or the agent the event names when agent_did is absent. Neither moves money. webhook_url is required for webhook and refused for the other two.
conditionobject,nullnoOptional. Every clause in all must hold for the event to fire this trigger. Which fields an event offers is listed per event by GET /v1/triggers/events (condition_fields); a field the event does not offer is refused with trigger_condition_invalid. in takes a list of strings; the other operators take one value. Without a condition every event of the type fires, as before.
eventstringyesThe event type to subscribe to. Matching is EXACT string equality — there is no prefix and no wildcard, so commerce.payment.succeeded does not receive commerce.payment.received. A name outside the list below is accepted and recorded (trigger.event_unknown) rather than refused, so you can subscribe ahead of a release; until something emits it the subscription simply never fires. What this build emits: approval.decided, approval.pending, collect.attempt.expired, collect.attempt.failed, collect.attempt.issued, collect.attempt.ready, collect.attempt.superseded, collect.link.paused, collect.link.published, collect.payment.exception, collect.payment.paid, collect.refund.awaiting_decision, collect.refund.honored, collect.refund.required, commerce.account_launch_in.received, commerce.account_launch_out.received, commerce.account_status.changed, commerce.cashout.confirmed, commerce.charge.cancelled, commerce.charge.created, commerce.charge.expired, commerce.charge.expiry_notified, commerce.charge.paid, commerce.charge.payment_notified, commerce.dict_claim.cancelled, commerce.dict_claim.completed, commerce.dict_claim.confirmed, commerce.dict_claim.opened, commerce.dict_claim.waiting, commerce.internal_transfer_in.received, commerce.internal_transfer_out.received, commerce.mandate.expiring, commerce.mandate.granted, commerce.mandate.paused, commerce.mandate.resumed, commerce.mandate.revoked, commerce.onboarding.backgroundcheck_approved, commerce.onboarding.backgroundcheck_pending, commerce.onboarding.backgroundcheck_rejected, commerce.onboarding.documentscopy_approved, commerce.onboarding.documentscopy_pending, commerce.onboarding.documentscopy_processing, commerce.onboarding.documentscopy_rejected, commerce.onboarding.proposal_approved, commerce.onboarding.proposal_processing_documentscopy, commerce.onboarding.proposal_rejected, commerce.organization.paused, commerce.organization.resumed, commerce.payment.failed, commerce.payment.pending, commerce.payment.received, commerce.payment.refunded, commerce.payment.succeeded, commerce.payment.updated, commerce.pix_out.failed, commerce.pix_out.succeeded, commerce.pix_out.unconfirmed, commerce.pix_reversal_in.received, commerce.pix_reversal_out.received, commerce.recurrence.authorized, commerce.recurrence.cancelled, commerce.recurrence.cycle.accepted, commerce.recurrence.cycle.announced_late, commerce.recurrence.cycle.awaiting_instruction, commerce.recurrence.cycle.cancelled, commerce.recurrence.cycle.expired, commerce.recurrence.cycle.instruction_blocked, commerce.recurrence.cycle.instruction_missing, commerce.recurrence.cycle.instruction_refused, commerce.recurrence.cycle.paid, commerce.recurrence.cycle.queued_instruction_expired, commerce.recurrence.cycle.rejected, commerce.recurrence.cycle.scheduled, commerce.recurrence.cycle.settled_by_held_credit, commerce.recurrence.denied, commerce.recurrence.requested, commerce.recurrence.settlement_held, commerce.recurrence.settlement_hold_overdue, commerce.recurrence.settlement_hold_resolved, commerce.recurrence.settlement_possible_double_credit, commerce.rinne.observed, commerce.spend.failed, commerce.spend.settled, commerce.ted_in.succeeded, dda.boleto.registered, dda.subscription.activated, dda.subscription.failed, gate.paywall.charged, payable.rejected, proxy_call.failed, proxy_call.succeeded, session.closed, system.health.degraded, system.health.recovered, tool_call.failed, tool_call.succeeded, trigger.delivery.failed, trigger.paused_automatically, trigger.test_fire, user.signed_up, wallet.balance.below.
namestringyes—
server_idstringno—
webhook_urlstring (uri)noRequired for the webhook action (the default), refused for the internal ones.

Responses

StatusBodyDescription
201TriggerCreatedOK
400objectBad Request — the body or query did not match the schema.
Example request
curl -X POST https://api.codespar.dev/v1/triggers \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "name": "Example",
       "event": "string",
       "server_id": "srv_0000000000000000",
       "webhook_url": "https://example.com/hook",
       "condition": {
         "all": [
           {
             "field": "amount_minor",
             "op": "gt",
             "value": 1000
           }
         ]
       },
       "action": {
         "kind": "webhook"
       }
     }'
POST /v1/triggers HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "name": "Example",
  "event": "string",
  "server_id": "srv_0000000000000000",
  "webhook_url": "https://example.com/hook",
  "condition": {
    "all": [
      {
        "field": "amount_minor",
        "op": "gt",
        "value": 1000
      }
    ]
  },
  "action": {
    "kind": "webhook"
  }
}
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/triggers",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "name": "Example",
      "event": "string",
      "server_id": "srv_0000000000000000",
      "webhook_url": "https://example.com/hook",
      "condition": {
        "all": [
          {
            "field": "amount_minor",
            "op": "gt",
            "value": 1000
          }
        ]
      },
      "action": {
        "kind": "webhook"
      }
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "name": "Example",
    "event": "string",
    "server_id": "srv_0000000000000000",
    "webhook_url": "https://example.com/hook",
    "condition": {
      "all": [
        {
          "field": "amount_minor",
          "op": "gt",
          "value": 1000
        }
      ]
    },
    "action": {
      "kind": "webhook"
    }
  }),
});

const data = await res.json();
const result = await cs.api.post("/v1/triggers", {
  body: {
    name: "Example",
    event: "string",
    server_id: "srv_0000000000000000",
    webhook_url: "https://example.com/hook",
    condition: {
      all: [
        {
          field: "amount_minor",
          op: "gt",
          value: 1000
        }
      ]
    },
    action: {
      kind: "webhook"
    }
  }
});

POST /v1/triggers/deliveries/{delivery_id}/redeliver

POSThttps://api.codespar.dev/v1/triggers/deliveries/{delivery_id}/redeliver
Deprecated

Queue a delivery to be sent again (deprecated path)

DEPRECATED alias of POST /v1/webhook-endpoints/deliveries/{delivery_id}/redeliver (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.

Queues a fresh attempt at the SAME event, typically for a dead lettered one. Requires triggers:write. The handler reads no request body. Note the path: the delivery id is enough, no endpoint id, because the delivery is resolved by joining its endpoint and filtering on the caller's organization and project.

NOTHING IS SENT BY THIS CALL. It inserts a new delivery row with attempt = 1, status pending and next_retry_at = now(), then answers 202. The POST happens when a drain picks the row up, either the in-process worker or the retry-pending call. Since a drain only claims rows whose endpoint is active, and this call deliberately does NOT reactivate a paused or auto-paused endpoint, read trigger_status in the response: anything other than active means the row will sit unclaimed until a patch reactivates the endpoint.

COUNT A REDELIVERY'S ATTEMPTS FROM 2. The row inserted here is a marker, not an attempt: the drain reads its attempt and dispatches at attempt + 1, so the first POST a redelivery actually makes carries attempt 2, and the retry ladder is entered at that rung rather than at the top. A redelivery therefore gets FOUR real POSTs, attempts 2 through 5, spaced by the ladder's 5 minute, 30 minute and 2 hour rungs, and the fifth is terminal — it dead letters instead of scheduling a sixth. Redelivering does not hand the event a fresh set of five.

It is NOT idempotent. Two calls queue two attempts at the same event and the subscriber receives it twice, with the same X-CodeSpar-Event-Id.

Two side effects on top of the insert: the origin row's next_retry_at is cleared so it cannot be claimed as well, and the endpoint's consecutive failure streak is reset to 0, which restarts the auto-pause clock.

Path parameters

NameTypeRequiredDescription
delivery_idstringyesDelivery id as decimal digits, the string form of the bigserial column. Up to 19 digits, which is the width of a signed 64 bit maximum; anything else is refused with 400 invalid_delivery_id before the database is touched.

Responses

StatusBodyDescription
202objectAccepted
400objectdelivery_id is not 1 to 19 decimal digits.
404objectNo delivery with that id whose webhook endpoint belongs to the caller's organization and project.

Response 202

FieldTypeRequiredDescription
event_idstringyes—
redelivery_idstringyesId of the NEW pending delivery row, bigint as string.
trigger_idstringyes—
trigger_statusstringyesStatus of the endpoint the delivery belongs to, read before the insert. Check it: the drain only claims rows whose endpoint is active, so a redelivery queued against a paused or error endpoint sits there until a patch reactivates it.
Example request
curl -X POST https://api.codespar.dev/v1/triggers/deliveries/{delivery_id}/redeliver \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
POST /v1/triggers/deliveries/{delivery_id}/redeliver HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/triggers/deliveries/{delivery_id}/redeliver",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/deliveries/{delivery_id}/redeliver", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.post("/v1/triggers/deliveries/{delivery_id}/redeliver", {
  path: {
    delivery_id: "delivery_0000000000000000"
  }
});
Example response 202
application/json
{
  "redelivery_id": "redelivery_0000000000000000",
  "trigger_id": "trg_0000000000000000",
  "event_id": "event_0000000000000000",
  "trigger_status": "string"
}

GET /v1/triggers/events

GEThttps://api.codespar.dev/v1/triggers/events

List the event types a trigger can subscribe to

Every event type this build emits, read out of the publishers when the build is made. A subscription to a name outside this list is still accepted, and never fires until a release starts emitting it. No labels: a client keeps its own copy, keyed by type.

Responses

StatusBodyDescription
200objectOK

Response 200

FieldTypeRequiredDescription
eventsarray of objectyes—
Example request
curl -X GET https://api.codespar.dev/v1/triggers/events \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/triggers/events HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/triggers/events",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/events", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/triggers/events");
Example response 200
application/json
{
  "events": [
    {
      "type": "string",
      "family": "string",
      "condition_fields": [
        {
          "field": "string",
          "type": "integer",
          "ops": [
            "gt"
          ],
          "crossing": true
        }
      ]
    }
  ]
}

POST /v1/triggers/retry-pending

POSThttps://api.codespar.dev/v1/triggers/retry-pending
Deprecated

Drain the retry queue for this project (deprecated path)

DEPRECATED alias of POST /v1/webhook-endpoints/retry-pending (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.

Dispatches the deliveries that are due for another attempt, right now, instead of waiting for the in-process worker tick. Requires triggers:write. The handler reads no request body and takes no parameters.

A row is claimed only when ALL of it holds: its status is failed or pending, its next_retry_at has passed, and its endpoint is active. An endpoint that auto-paused into error therefore drains nothing until a patch reactivates it. Scope is the caller's organization AND project; one project cannot drain a sibling project's queue.

One call claims at most 100 rows, and the limit is not settable from here, so a backlog larger than that needs repeated calls. The claim uses FOR UPDATE SKIP LOCKED, so calling this while the worker is running is safe: the two get disjoint rows rather than dispatching the same event twice.

The counters describe what happened during the call. scanned is what was claimed; a claimed row is dispatched synchronously before this returns, so a large drain is a slow request.

Responses

StatusBodyDescription
200objectOK

Response 200

FieldTypeRequiredDescription
deadintegeryesAttempts that failed on the last allowed attempt and were dead lettered.
deliveredintegeryes—
failedintegeryesAttempts that failed and are scheduled for another retry.
redispatchedintegeryes—
scannedintegeryesRows the claim actually took, which is also the ceiling on the four counters below.
Example request
curl -X POST https://api.codespar.dev/v1/triggers/retry-pending \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
POST /v1/triggers/retry-pending HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/triggers/retry-pending",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/retry-pending", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.post("/v1/triggers/retry-pending");
Example response 200
application/json
{
  "scanned": 0,
  "redispatched": 0,
  "delivered": 0,
  "failed": 0,
  "dead": 0
}

POST /v1/triggers/simulate

POSThttps://api.codespar.dev/v1/triggers/simulate

Count how many recent events a condition would have matched

Evaluates condition over this project's stored events of event_type in the window, with the same evaluation the fan-out uses, and answers how many matched. Nothing is delivered, no action runs, nothing is written. A test-fire is not counted. At most the window's 1000 most recent events are read; when there were more, truncated is true and matched is a floor. Without a condition every event matches, which is what a trigger without one does.

Request body

FieldTypeRequiredDescription
conditionobject,nullnoOptional. Every clause in all must hold for the event to fire this trigger. Which fields an event offers is listed per event by GET /v1/triggers/events (condition_fields); a field the event does not offer is refused with trigger_condition_invalid. in takes a list of strings; the other operators take one value. Without a condition every event of the type fires, as before.
event_typestringyes—
window"1h" | "24h" | "7d"no—

Responses

StatusBodyDescription
200objectOK
400objectThe body did not match the schema, or the condition names a field the event does not carry.

Response 200

FieldTypeRequiredDescription
event_typestringyes—
matchedintegeryes—
sample_event_idsarray of stringyesUp to 10 matching events, most recent first.
scannedintegeryes—
truncatedbooleanyes—
windowstringyes—
Example request
curl -X POST https://api.codespar.dev/v1/triggers/simulate \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "event_type": "string",
       "condition": {
         "all": [
           {
             "field": "amount_minor",
             "op": "gt",
             "value": 1000
           }
         ]
       },
       "window": "24h"
     }'
POST /v1/triggers/simulate HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "event_type": "string",
  "condition": {
    "all": [
      {
        "field": "amount_minor",
        "op": "gt",
        "value": 1000
      }
    ]
  },
  "window": "24h"
}
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/triggers/simulate",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "event_type": "string",
      "condition": {
        "all": [
          {
            "field": "amount_minor",
            "op": "gt",
            "value": 1000
          }
        ]
      },
      "window": "24h"
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/simulate", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "event_type": "string",
    "condition": {
      "all": [
        {
          "field": "amount_minor",
          "op": "gt",
          "value": 1000
        }
      ]
    },
    "window": "24h"
  }),
});

const data = await res.json();
const result = await cs.api.post("/v1/triggers/simulate", {
  body: {
    event_type: "string",
    condition: {
      all: [
        {
          field: "amount_minor",
          op: "gt",
          value: 1000
        }
      ]
    },
    window: "24h"
  }
});
Example response 200
application/json
{
  "event_type": "string",
  "window": "string",
  "scanned": 0,
  "matched": 0,
  "sample_event_ids": [
    "string"
  ],
  "truncated": true
}

GET /v1/triggers/{id}

GEThttps://api.codespar.dev/v1/triggers/{id}
Deprecated

Read one webhook endpoint (deprecated path)

DEPRECATED alias of GET /v1/webhook-endpoints/{id} (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.

The stored configuration of one webhook endpoint. Requires triggers:read.

The signing secret is never returned; signing_enabled reports only whether one exists. An id belonging to another organization, or to another project in the caller's own organization, answers 404 and not 403: both filters are in the WHERE clause, so the handler cannot tell absent from someone else's, and must not, because telling them apart confirms the id exists.

Path parameters

NameTypeRequiredDescription
idstringyesEndpoint id, trg_ followed by a 16 character nanoid, as minted by the create call.

Responses

StatusBodyDescription
200objectOK
404objectNo webhook endpoint with that id in the caller's organization and project.

Response 200

FieldTypeRequiredDescription
actionobject | object | objectyesWhat the trigger does when it fires. Absent means webhook, the signed POST to webhook_url. human_review puts an item in the approvals inbox; pause_agent suspends the agent named by agent_did, or the agent the event names when agent_did is absent. Neither moves money. webhook_url is required for webhook and refused for the other two.
conditionobject,nullyesNull fires on every event of the type.
created_atstring (date-time)yes—
eventstringyesThe event name this endpoint subscribes to, dot separated lowercase.
event_knownbooleanyesWhether event is a type this build emits. false means the subscription exists but will not fire until a release starts emitting that name: it was accepted so you can subscribe ahead of a release, not because it matches.
idstringyes—
last_run_atstring,null (date-time)yesTimestamp of the last DELIVERED attempt, on the same rule as total_runs.
namestringyes—
org_idstringyes—
project_idstring,nullyesNullable in the row type this response is serialized from; a later migration sets the column NOT NULL, so an endpoint created since then always carries one.
server_idstring,nullyes—
signing_enabledbooleanyesWhether a signing secret exists. The secret itself is never read back: the serializer drops the vault reference and reports only this boolean.
statusstringyesactive, paused or error. Left as an open string because the column is text with no CHECK constraint: active and paused are what a patch may set, and error is what the dispatcher writes on its own when an endpoint auto-pauses after enough consecutive dead deliveries.
total_runsintegeryesDeliveries that landed. A failed or dead attempt does not count, which is why this can sit at 0 while the deliveries listing is full of rows.
webhook_urlstring,nullyesNull when the action is internal (human_review, pause_agent).
Example request
curl -X GET https://api.codespar.dev/v1/triggers/{id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/triggers/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/triggers/{id}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/{id}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/triggers/{id}", {
  path: {
    id: "trg_0000000000000000"
  }
});
Example response 200
application/json
{
  "id": "obj_0000000000000000",
  "org_id": "org_0000000000000000",
  "project_id": "prj_0000000000000000",
  "name": "Example",
  "event": "string",
  "server_id": "srv_0000000000000000",
  "webhook_url": "https://example.com/hook",
  "status": "string",
  "total_runs": 0,
  "last_run_at": "2026-01-15T12:00:00.000Z",
  "created_at": "2026-01-15T12:00:00.000Z",
  "signing_enabled": true,
  "event_known": true,
  "condition": {
    "all": [
      {
        "field": "amount_minor",
        "op": "gt",
        "value": 1000
      }
    ]
  },
  "action": {
    "kind": "webhook"
  }
}

PATCH /v1/triggers/{id}

PATCHhttps://api.codespar.dev/v1/triggers/{id}
Deprecated

Update a webhook endpoint (deprecated path)

DEPRECATED alias of PATCH /v1/webhook-endpoints/{id} (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.

Changes the name, URL and status of a subscription. Only the fields sent change; the others stay as they are.

The webhook_url goes through the same syntax check as the create call: HTTPS, and no loopback, private or metadata host. DNS is rechecked at dispatch, so passing here does not promise that it resolves publicly later.

Path parameters

NameTypeRequiredDescription
idstringyesEndpoint id, trg_ followed by a 16 character nanoid, as minted by the create call.

Request body

FieldTypeRequiredDescription
actionobject | object | objectnoReplace the action. Switching to an internal one drops webhook_url; switching to webhook needs one.
conditionobject,nullnoReplace the condition, or null to remove it. Absent leaves it as it is.
namestringno—
status"active" | "paused"noOnly active and paused are settable. error shows up on read but belongs to the dispatcher: it writes that value on its own when an endpoint auto-pauses after enough consecutive dead deliveries, and a patch cannot reach it.
webhook_urlstring (uri)no—

Responses

StatusBodyDescription
200objectOK
400objectThe body did not match the schema (the empty body included), the webhook_url was refused by the syntax check, or the condition names a field the endpoint's event does not carry (trigger_condition_invalid), or the action could not run on it (trigger_action_invalid); both name the cause in details.reason.
404objectNo webhook endpoint with that id in the caller's organization and project.

Response 200

FieldTypeRequiredDescription
actionobject | object | objectyesWhat the trigger does when it fires. Absent means webhook, the signed POST to webhook_url. human_review puts an item in the approvals inbox; pause_agent suspends the agent named by agent_did, or the agent the event names when agent_did is absent. Neither moves money. webhook_url is required for webhook and refused for the other two.
conditionobject,nullyesNull fires on every event of the type.
created_atstring (date-time)yes—
eventstringyesThe event name this endpoint subscribes to, dot separated lowercase.
event_knownbooleanyesWhether event is a type this build emits. false means the subscription exists but will not fire until a release starts emitting that name: it was accepted so you can subscribe ahead of a release, not because it matches.
idstringyes—
last_run_atstring,null (date-time)yesTimestamp of the last DELIVERED attempt, on the same rule as total_runs.
namestringyes—
org_idstringyes—
project_idstring,nullyesNullable in the row type this response is serialized from; a later migration sets the column NOT NULL, so an endpoint created since then always carries one.
server_idstring,nullyes—
signing_enabledbooleanyesWhether a signing secret exists. The secret itself is never read back: the serializer drops the vault reference and reports only this boolean.
statusstringyesactive, paused or error. Left as an open string because the column is text with no CHECK constraint: active and paused are what a patch may set, and error is what the dispatcher writes on its own when an endpoint auto-pauses after enough consecutive dead deliveries.
total_runsintegeryesDeliveries that landed. A failed or dead attempt does not count, which is why this can sit at 0 while the deliveries listing is full of rows.
webhook_urlstring,nullyesNull when the action is internal (human_review, pause_agent).
Example request
curl -X PATCH https://api.codespar.dev/v1/triggers/{id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "name": "Example",
       "webhook_url": "https://example.com/hook",
       "status": "active",
       "condition": {
         "all": [
           {
             "field": "amount_minor",
             "op": "gt",
             "value": 1000
           }
         ]
       },
       "action": {
         "kind": "webhook"
       }
     }'
PATCH /v1/triggers/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "name": "Example",
  "webhook_url": "https://example.com/hook",
  "status": "active",
  "condition": {
    "all": [
      {
        "field": "amount_minor",
        "op": "gt",
        "value": 1000
      }
    ]
  },
  "action": {
    "kind": "webhook"
  }
}
import os
import requests

res = requests.patch(
    "https://api.codespar.dev/v1/triggers/{id}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "name": "Example",
      "webhook_url": "https://example.com/hook",
      "status": "active",
      "condition": {
        "all": [
          {
            "field": "amount_minor",
            "op": "gt",
            "value": 1000
          }
        ]
      },
      "action": {
        "kind": "webhook"
      }
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/{id}", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "name": "Example",
    "webhook_url": "https://example.com/hook",
    "status": "active",
    "condition": {
      "all": [
        {
          "field": "amount_minor",
          "op": "gt",
          "value": 1000
        }
      ]
    },
    "action": {
      "kind": "webhook"
    }
  }),
});

const data = await res.json();
const result = await cs.api.patch("/v1/triggers/{id}", {
  path: {
    id: "trg_0000000000000000"
  },
  body: {
    name: "Example",
    webhook_url: "https://example.com/hook",
    status: "active",
    condition: {
      all: [
        {
          field: "amount_minor",
          op: "gt",
          value: 1000
        }
      ]
    },
    action: {
      kind: "webhook"
    }
  }
});
Example response 200
application/json
{
  "id": "obj_0000000000000000",
  "org_id": "org_0000000000000000",
  "project_id": "prj_0000000000000000",
  "name": "Example",
  "event": "string",
  "server_id": "srv_0000000000000000",
  "webhook_url": "https://example.com/hook",
  "status": "string",
  "total_runs": 0,
  "last_run_at": "2026-01-15T12:00:00.000Z",
  "created_at": "2026-01-15T12:00:00.000Z",
  "signing_enabled": true,
  "event_known": true,
  "condition": {
    "all": [
      {
        "field": "amount_minor",
        "op": "gt",
        "value": 1000
      }
    ]
  },
  "action": {
    "kind": "webhook"
  }
}

DELETE /v1/triggers/{id}

DELETEhttps://api.codespar.dev/v1/triggers/{id}
Deprecated

Delete a webhook endpoint (deprecated path)

DEPRECATED alias of DELETE /v1/webhook-endpoints/{id} (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.

Removes the endpoint and purges its HMAC signing key in the SAME transaction. Requires triggers:write. A hard delete, because an endpoint is configuration and not an audit record.

The delivery history goes with it. The delivery table's foreign key onto this one is declared ON DELETE CASCADE, so this call also removes every attempt and every dead letter recorded for the endpoint; read the deliveries you still need first.

It fails CLOSED. If the vault cannot purge the key, the whole transaction rolls back, the endpoint is still there, and the answer is 503 vault_unavailable. The alternative would leave live signing material in the vault for an object the customer believes is gone, with no route left that could read, rotate or remove it. Retry once the vault is reachable.

That 503 does not distinguish causes. Its catch is the widest on this surface: any failure inside the transaction, not only an unreachable vault, comes back under this one code. What it always means is that nothing was deleted.

Path parameters

NameTypeRequiredDescription
idstringyesEndpoint id, trg_ followed by a 16 character nanoid, as minted by the create call.

Responses

StatusBodyDescription
204—No Content
404objectNo webhook endpoint with that id in the caller's organization and project.
503objectThe signing secret could not be purged, so nothing was deleted. Retriable.
Example request
curl -X DELETE https://api.codespar.dev/v1/triggers/{id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
DELETE /v1/triggers/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.delete(
    "https://api.codespar.dev/v1/triggers/{id}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/{id}", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.delete("/v1/triggers/{id}", {
  path: {
    id: "trg_0000000000000000"
  }
});

GET /v1/triggers/{id}/deliveries

GEThttps://api.codespar.dev/v1/triggers/{id}/deliveries
Deprecated

List recent delivery attempts (deprecated path)

DEPRECATED alias of GET /v1/webhook-endpoints/{id}/deliveries (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.

Attempts for one endpoint, newest first by created_at. Requires triggers:read. The endpoint is loaded under the caller's organization and project first, so a delivery cannot be read across tenants; an unknown or foreign endpoint id is 404.

limit is the ONLY control: there is no cursor and no total, so an attempt older than the 200 most recent is not reachable through this route. Each row is one attempt, not one event; a retried event appears once per attempt.

Path parameters

NameTypeRequiredDescription
idstringyesEndpoint id, trg_ followed by a 16 character nanoid, as minted by the create call.

Query parameters

NameTypeRequiredDescription
limitintegernoRows to return. Clamped into the range 1 to 200: a larger or smaller value is reduced or raised rather than refused, and a value that does not parse as a number falls back to 50.

Responses

StatusBodyDescription
200objectOK
404objectNo webhook endpoint with that id in the caller's organization and project.

Response 200

FieldTypeRequiredDescription
deliveriesarray of objectyes—
Example request
curl -X GET https://api.codespar.dev/v1/triggers/{id}/deliveries \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/triggers/{id}/deliveries HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/triggers/{id}/deliveries",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/{id}/deliveries", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/triggers/{id}/deliveries", {
  path: {
    id: "trg_0000000000000000"
  }
});
Example response 200
application/json
{
  "deliveries": [
    {
      "id": "obj_0000000000000000",
      "trigger_id": "trg_0000000000000000",
      "event_id": "event_0000000000000000",
      "attempt": 0,
      "status": "string",
      "response_status": 0,
      "error": "string",
      "delivered_at": "2026-01-15T12:00:00.000Z",
      "next_retry_at": "2026-01-15T12:00:00.000Z",
      "created_at": "2026-01-15T12:00:00.000Z"
    }
  ]
}

GET /v1/triggers/{id}/deliveries/{delivery_id}

GEThttps://api.codespar.dev/v1/triggers/{id}/deliveries/{delivery_id}
Deprecated

Inspect one delivery attempt (deprecated path)

DEPRECATED alias of GET /v1/webhook-endpoints/{id}/deliveries/{delivery_id} (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.

One attempt with the request that was sent, the response that came back, and the originating event. Requires triggers:read. This is the endpoint for answering did the subscriber ever see this event, and in what form.

request.body is rebuilt with the dispatcher's own serializer, so it is the byte sequence the signature covered: verify by taking the HMAC of <request.sent_at_unix>.<request.body> with the endpoint's signing secret. request.headers is re-derived for display rather than stored, the v1 value in the signature header is redacted, and the legacy signature header the dispatcher also sends is not listed there.

Order of checks: the endpoint is resolved first, so a foreign or unknown {id} is 404 even when {delivery_id} is malformed. A malformed {delivery_id} under an endpoint the caller owns is 400 invalid_delivery_id; a well formed id that names no attempt of THIS endpoint is 404.

Path parameters

NameTypeRequiredDescription
delivery_idstringyesDelivery id as decimal digits, the string form of the bigserial column. Up to 19 digits, which is the width of a signed 64 bit maximum; anything else is refused with 400 invalid_delivery_id before the database is touched.
idstringyesEndpoint id, trg_ followed by a 16 character nanoid, as minted by the create call.

Responses

StatusBodyDescription
200objectOK
400objectdelivery_id is not 1 to 19 decimal digits.
404objectEither no such webhook endpoint in the caller's organization and project, or no delivery with that id belonging to it. Both answer the same code, on purpose.

Response 200

FieldTypeRequiredDescription
deliveryobjectyes—
eventobjectyes—
requestobjectyes—
responseobjectyes—
Example request
curl -X GET https://api.codespar.dev/v1/triggers/{id}/deliveries/{delivery_id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/triggers/{id}/deliveries/{delivery_id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/triggers/{id}/deliveries/{delivery_id}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/{id}/deliveries/{delivery_id}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/triggers/{id}/deliveries/{delivery_id}", {
  path: {
    id: "trg_0000000000000000",
    delivery_id: "delivery_0000000000000000"
  }
});
Example response 200
application/json
{
  "delivery": {
    "id": "obj_0000000000000000",
    "trigger_id": "trg_0000000000000000",
    "event_id": "event_0000000000000000",
    "attempt": 0,
    "status": "string",
    "response_status": 0,
    "error": "string",
    "delivered_at": "2026-01-15T12:00:00.000Z",
    "next_retry_at": "2026-01-15T12:00:00.000Z",
    "dead_at": "2026-01-15T12:00:00.000Z",
    "receipt_at": "2026-01-15T12:00:00.000Z",
    "created_at": "2026-01-15T12:00:00.000Z"
  },
  "request": {
    "url": "https://example.com/hook",
    "sent_at_unix": 0,
    "headers": {
      "Content-Type": "string",
      "X-CodeSpar-Event": "string",
      "X-CodeSpar-Event-Id": "xcodesparevent_0000000000000000",
      "X-CodeSpar-Trigger-Id": "xcodespartrigger_0000000000000000",
      "X-CodeSpar-Attempt": "string",
      "X-CodeSpar-Signature": "string"
    },
    "body": "string"
  },
  "response": {
    "status": 0,
    "body": "string",
    "error": "string"
  },
  "event": {
    "id": "obj_0000000000000000",
    "source": "string",
    "event_type": "string",
    "payload": {},
    "received_at": "2026-01-15T12:00:00.000Z",
    "provider_event_id": "providerevent_0000000000000000"
  }
}

GET /v1/triggers/{id}/dlq

GEThttps://api.codespar.dev/v1/triggers/{id}/dlq
Deprecated

List dead lettered deliveries (deprecated path)

DEPRECATED alias of GET /v1/webhook-endpoints/{id}/dlq (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.

The attempts that exhausted the retry ladder, status = 'dead', newest first by dead_at with nulls last. Requires triggers:read. Separate from the deliveries listing so an operator can page the failures without wading through successful attempts.

A FIRST delivery reaches here after five attempts: the initial one plus four retries, whose earliest times are 1 minute, 5 minutes, 30 minutes and 2 hours after the attempt they follow, so about two and a half hours at best. A retry only fires when a drain claims the row, so the real elapsed time can be longer. A REDELIVERY gets fewer, and the redeliver operation explains why. Nothing retries a dead row on its own: it is replayed only by the redeliver call.

Same single limit control as the deliveries listing, with no cursor.

Path parameters

NameTypeRequiredDescription
idstringyesEndpoint id, trg_ followed by a 16 character nanoid, as minted by the create call.

Query parameters

NameTypeRequiredDescription
limitintegernoRows to return. Clamped into the range 1 to 200: a larger or smaller value is reduced or raised rather than refused, and a value that does not parse as a number falls back to 50.

Responses

StatusBodyDescription
200objectOK
404objectNo webhook endpoint with that id in the caller's organization and project.

Response 200

FieldTypeRequiredDescription
dead_lettersarray of objectyes—
Example request
curl -X GET https://api.codespar.dev/v1/triggers/{id}/dlq \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/triggers/{id}/dlq HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/triggers/{id}/dlq",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/{id}/dlq", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/triggers/{id}/dlq", {
  path: {
    id: "trg_0000000000000000"
  }
});
Example response 200
application/json
{
  "dead_letters": [
    {
      "id": "obj_0000000000000000",
      "event_id": "event_0000000000000000",
      "attempt": 0,
      "response_status": 0,
      "error": "string",
      "dead_at": "2026-01-15T12:00:00.000Z",
      "created_at": "2026-01-15T12:00:00.000Z"
    }
  ]
}

POST /v1/triggers/{id}/rotate-secret

POSThttps://api.codespar.dev/v1/triggers/{id}/rotate-secret
Deprecated

Mint a new signing secret for a webhook endpoint (deprecated path)

DEPRECATED alias of POST /v1/webhook-endpoints/{id}/rotate-secret (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.

Generates a new HMAC secret and overwrites the vault entry in place. Requires triggers:write. The handler reads no request body.

THERE IS NO OVERLAP WINDOW. The vault reference does not change, so the next dispatch that dereferences it signs with the new secret and the old one stops verifying the moment this transaction commits. Install the returned value in the subscriber before the next event fires, or those deliveries fail signature checks on the far side while still counting as delivered here.

The plaintext is revealed exactly once, in this response. The vault write and the row update share one transaction, so a failure leaves neither half standing and the answer is 503 vault_unavailable with no rotation performed: the previous secret keeps working.

Path parameters

NameTypeRequiredDescription
idstringyesEndpoint id, trg_ followed by a 16 character nanoid, as minted by the create call.

Responses

StatusBodyDescription
200objectOK
404objectNo webhook endpoint with that id in the caller's organization and project.
503objectThe new secret could not be persisted, so no rotation occurred and the previous secret is still in force. Retriable.

Response 200

FieldTypeRequiredDescription
secretstringyesThe new signing secret in plaintext, 32 random bytes as hex, shown EXACTLY ONCE. Later reads expose only signing_enabled, and a lost secret is replaced by rotating again.
signing_enabledtrueyes—
trigger_idstringyes—
Example request
curl -X POST https://api.codespar.dev/v1/triggers/{id}/rotate-secret \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
POST /v1/triggers/{id}/rotate-secret HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/triggers/{id}/rotate-secret",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/{id}/rotate-secret", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.post("/v1/triggers/{id}/rotate-secret", {
  path: {
    id: "trg_0000000000000000"
  }
});
Example response 200
application/json
{
  "trigger_id": "trg_0000000000000000",
  "signing_enabled": true,
  "secret": "string"
}

POST /v1/triggers/{id}/test-fire

POSThttps://api.codespar.dev/v1/triggers/{id}/test-fire
Deprecated

Deliver a synthetic event to a webhook endpoint (deprecated path)

DEPRECATED alias of POST /v1/webhook-endpoints/{id}/test-fire (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.

Publishes a synthetic event on this subscription and delivers it at once, so you can watch the request arrive on the other side without waiting for a real event.

The endpoint has to be active: a paused one is refused with 409 rather than queued in silence. The delivered body carries test: true and the trigger_id, written by the server AFTER your payload, so those two markers cannot be forged by the caller.

Path parameters

NameTypeRequiredDescription
idstringyesEndpoint id, trg_ followed by a 16 character nanoid, as minted by the create call.

Request body

FieldTypeRequiredDescription
event_typestringnoDot separated lowercase, the same shape as a subscription's event. When absent, the synthetic event goes out as trigger.test_fire.
payloadobjectnoEXTRA fields in the event body. They do not overwrite the markers: the server writes test: true, trigger_id and requested_at LAST, so a subscriber that decides on test never receives a test fire disguised as a real one.

Responses

StatusBodyDescription
200objectOK
400objectThe body did not match the schema.
404objectNo webhook endpoint with that id in the caller's organization and project.
409objectThe subscription is not active (its current status comes back in details.status), or its action is internal (human_review, pause_agent) and would run for real: simulate instead.

Response 200

FieldTypeRequiredDescription
delivery_idstring,nullyesNull when the dispatch never got as far as recording an attempt.
errorstring,nullyesWhy the attempt failed, in the same form as the delivery history.
event_idstringyesThe synthetic event, persisted so the delivery has a real target.
response_statusinteger,nullyesThe HTTP status the subscriber answered, or null when there was no response.
statusstringyesThe outcome of the attempt, as the dispatcher wrote it.
trigger_idstringyes—
Example request
curl -X POST https://api.codespar.dev/v1/triggers/{id}/test-fire \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "event_type": "string",
       "payload": {}
     }'
POST /v1/triggers/{id}/test-fire HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "event_type": "string",
  "payload": {}
}
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/triggers/{id}/test-fire",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "event_type": "string",
      "payload": {}
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/triggers/{id}/test-fire", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "event_type": "string",
    "payload": {}
  }),
});

const data = await res.json();
const result = await cs.api.post("/v1/triggers/{id}/test-fire", {
  path: {
    id: "trg_0000000000000000"
  },
  body: {
    event_type: "string",
    payload: {}
  }
});
Example response 200
application/json
{
  "trigger_id": "trg_0000000000000000",
  "event_id": "event_0000000000000000",
  "delivery_id": "delivery_0000000000000000",
  "status": "string",
  "response_status": 0,
  "error": "string"
}
Triggers | CodeSpar