Webhook Endpoints
12 operations under /v1/webhook-endpoints (GET POST PATCH DELETE): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.
Base URL: https://api.codespar.dev
Every operation below requires a Bearer token. See Authentication.
GET /v1/webhook-endpoints
https://api.codespar.dev/v1/webhook-endpointsList triggers
Same cursor shape as /v1/sessions. The signing secret is never included. Each endpoint carries its delivery health: consecutive_failures (failed or dead attempts since the last delivered one, not the dead-outcome streak the auto-pause counts), last_response_status and last_delivery_at of the newest finished attempt.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
before | string | no | — |
event | string | no | — |
limit | integer | no | — |
server_id | string | no | — |
status | "active" | "paused" | "error" | no | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | Bad Request — the body or query did not match the schema. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
next_before | string,null | yes | — |
triggers | array of — | yes | — |
curl -X GET https://api.codespar.dev/v1/webhook-endpoints \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/webhook-endpoints HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/webhook-endpoints",
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/webhook-endpoints", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/webhook-endpoints");{
"triggers": [],
"next_before": "string"
}POST /v1/webhook-endpoints
https://api.codespar.dev/v1/webhook-endpointsSubscribe a webhook to an event
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
| Field | Type | Required | Description |
|---|---|---|---|
action | object | object | object | no | What 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. |
condition | object,null | no | Optional. 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 | string | yes | The 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. |
name | string | yes | — |
server_id | string | no | — |
webhook_url | string (uri) | no | Required for the webhook action (the default), refused for the internal ones. |
Responses
| Status | Body | Description |
|---|---|---|
201 | TriggerCreated | OK |
400 | object | Bad Request — the body or query did not match the schema. |
curl -X POST https://api.codespar.dev/v1/webhook-endpoints \
-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/webhook-endpoints 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/webhook-endpoints",
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/webhook-endpoints", {
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/webhook-endpoints", {
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/webhook-endpoints/deliveries/{delivery_id}/redeliver
https://api.codespar.dev/v1/webhook-endpoints/deliveries/{delivery_id}/redeliverQueue a delivery to be sent again
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
| Name | Type | Required | Description |
|---|---|---|---|
delivery_id | string | yes | Delivery 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
| Status | Body | Description |
|---|---|---|
202 | object | Accepted |
400 | object | delivery_id is not 1 to 19 decimal digits. |
404 | object | No delivery with that id whose webhook endpoint belongs to the caller's organization and project. |
Response 202
| Field | Type | Required | Description |
|---|---|---|---|
event_id | string | yes | — |
redelivery_id | string | yes | Id of the NEW pending delivery row, bigint as string. |
trigger_id | string | yes | — |
trigger_status | string | yes | Status 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. |
curl -X POST https://api.codespar.dev/v1/webhook-endpoints/deliveries/{delivery_id}/redeliver \
-H "Authorization: Bearer $CODESPAR_API_KEY"POST /v1/webhook-endpoints/deliveries/{delivery_id}/redeliver HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.post(
"https://api.codespar.dev/v1/webhook-endpoints/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/webhook-endpoints/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/webhook-endpoints/deliveries/{delivery_id}/redeliver", {
path: {
delivery_id: "delivery_0000000000000000"
}
});{
"redelivery_id": "redelivery_0000000000000000",
"trigger_id": "trg_0000000000000000",
"event_id": "event_0000000000000000",
"trigger_status": "string"
}POST /v1/webhook-endpoints/retry-pending
https://api.codespar.dev/v1/webhook-endpoints/retry-pendingDrain the retry queue for this project
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
| Status | Body | Description |
|---|---|---|
200 | object | OK |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
dead | integer | yes | Attempts that failed on the last allowed attempt and were dead lettered. |
delivered | integer | yes | — |
failed | integer | yes | Attempts that failed and are scheduled for another retry. |
redispatched | integer | yes | — |
scanned | integer | yes | Rows the claim actually took, which is also the ceiling on the four counters below. |
curl -X POST https://api.codespar.dev/v1/webhook-endpoints/retry-pending \
-H "Authorization: Bearer $CODESPAR_API_KEY"POST /v1/webhook-endpoints/retry-pending HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.post(
"https://api.codespar.dev/v1/webhook-endpoints/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/webhook-endpoints/retry-pending", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.post("/v1/webhook-endpoints/retry-pending");{
"scanned": 0,
"redispatched": 0,
"delivered": 0,
"failed": 0,
"dead": 0
}GET /v1/webhook-endpoints/{id}
https://api.codespar.dev/v1/webhook-endpoints/{id}Read one webhook endpoint
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Endpoint id, trg_ followed by a 16 character nanoid, as minted by the create call. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | No webhook endpoint with that id in the caller's organization and project. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
action | object | object | object | yes | What 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. |
condition | object,null | yes | Null fires on every event of the type. |
created_at | string (date-time) | yes | — |
event | string | yes | The event name this endpoint subscribes to, dot separated lowercase. |
event_known | boolean | yes | Whether 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. |
id | string | yes | — |
last_run_at | string,null (date-time) | yes | Timestamp of the last DELIVERED attempt, on the same rule as total_runs. |
name | string | yes | — |
org_id | string | yes | — |
project_id | string,null | yes | Nullable 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_id | string,null | yes | — |
signing_enabled | boolean | yes | Whether a signing secret exists. The secret itself is never read back: the serializer drops the vault reference and reports only this boolean. |
status | string | yes | active, 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_runs | integer | yes | Deliveries 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_url | string,null | yes | Null when the action is internal (human_review, pause_agent). |
curl -X GET https://api.codespar.dev/v1/webhook-endpoints/{id} \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/webhook-endpoints/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/webhook-endpoints/{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/webhook-endpoints/{id}", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/webhook-endpoints/{id}", {
path: {
id: "webhookendpoint_0000000000000000"
}
});{
"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/webhook-endpoints/{id}
https://api.codespar.dev/v1/webhook-endpoints/{id}Update a webhook endpoint
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Endpoint id, trg_ followed by a 16 character nanoid, as minted by the create call. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
action | object | object | object | no | Replace the action. Switching to an internal one drops webhook_url; switching to webhook needs one. |
condition | object,null | no | Replace the condition, or null to remove it. Absent leaves it as it is. |
name | string | no | — |
status | "active" | "paused" | no | Only 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_url | string (uri) | no | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | The 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. |
404 | object | No webhook endpoint with that id in the caller's organization and project. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
action | object | object | object | yes | What 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. |
condition | object,null | yes | Null fires on every event of the type. |
created_at | string (date-time) | yes | — |
event | string | yes | The event name this endpoint subscribes to, dot separated lowercase. |
event_known | boolean | yes | Whether 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. |
id | string | yes | — |
last_run_at | string,null (date-time) | yes | Timestamp of the last DELIVERED attempt, on the same rule as total_runs. |
name | string | yes | — |
org_id | string | yes | — |
project_id | string,null | yes | Nullable 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_id | string,null | yes | — |
signing_enabled | boolean | yes | Whether a signing secret exists. The secret itself is never read back: the serializer drops the vault reference and reports only this boolean. |
status | string | yes | active, 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_runs | integer | yes | Deliveries 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_url | string,null | yes | Null when the action is internal (human_review, pause_agent). |
curl -X PATCH https://api.codespar.dev/v1/webhook-endpoints/{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/webhook-endpoints/{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/webhook-endpoints/{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/webhook-endpoints/{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/webhook-endpoints/{id}", {
path: {
id: "webhookendpoint_0000000000000000"
},
body: {
name: "Example",
webhook_url: "https://example.com/hook",
status: "active",
condition: {
all: [
{
field: "amount_minor",
op: "gt",
value: 1000
}
]
},
action: {
kind: "webhook"
}
}
});{
"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/webhook-endpoints/{id}
https://api.codespar.dev/v1/webhook-endpoints/{id}Delete a webhook endpoint
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Endpoint id, trg_ followed by a 16 character nanoid, as minted by the create call. |
Responses
| Status | Body | Description |
|---|---|---|
204 | — | No Content |
404 | object | No webhook endpoint with that id in the caller's organization and project. |
503 | object | The signing secret could not be purged, so nothing was deleted. Retriable. |
curl -X DELETE https://api.codespar.dev/v1/webhook-endpoints/{id} \
-H "Authorization: Bearer $CODESPAR_API_KEY"DELETE /v1/webhook-endpoints/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.delete(
"https://api.codespar.dev/v1/webhook-endpoints/{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/webhook-endpoints/{id}", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.delete("/v1/webhook-endpoints/{id}", {
path: {
id: "webhookendpoint_0000000000000000"
}
});GET /v1/webhook-endpoints/{id}/deliveries
https://api.codespar.dev/v1/webhook-endpoints/{id}/deliveriesList recent delivery attempts
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Endpoint id, trg_ followed by a 16 character nanoid, as minted by the create call. |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Rows 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
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | No webhook endpoint with that id in the caller's organization and project. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
deliveries | array of object | yes | — |
curl -X GET https://api.codespar.dev/v1/webhook-endpoints/{id}/deliveries \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/webhook-endpoints/{id}/deliveries HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/webhook-endpoints/{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/webhook-endpoints/{id}/deliveries", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/webhook-endpoints/{id}/deliveries", {
path: {
id: "webhookendpoint_0000000000000000"
}
});{
"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/webhook-endpoints/{id}/deliveries/{delivery_id}
https://api.codespar.dev/v1/webhook-endpoints/{id}/deliveries/{delivery_id}Inspect one delivery attempt
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
| Name | Type | Required | Description |
|---|---|---|---|
delivery_id | string | yes | Delivery 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. |
id | string | yes | Endpoint id, trg_ followed by a 16 character nanoid, as minted by the create call. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | delivery_id is not 1 to 19 decimal digits. |
404 | object | Either 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
| Field | Type | Required | Description |
|---|---|---|---|
delivery | object | yes | — |
event | object | yes | — |
request | object | yes | — |
response | object | yes | — |
curl -X GET https://api.codespar.dev/v1/webhook-endpoints/{id}/deliveries/{delivery_id} \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/webhook-endpoints/{id}/deliveries/{delivery_id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/webhook-endpoints/{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/webhook-endpoints/{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/webhook-endpoints/{id}/deliveries/{delivery_id}", {
path: {
id: "webhookendpoint_0000000000000000",
delivery_id: "delivery_0000000000000000"
}
});{
"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/webhook-endpoints/{id}/dlq
https://api.codespar.dev/v1/webhook-endpoints/{id}/dlqList dead lettered deliveries
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Endpoint id, trg_ followed by a 16 character nanoid, as minted by the create call. |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Rows 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
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | No webhook endpoint with that id in the caller's organization and project. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
dead_letters | array of object | yes | — |
curl -X GET https://api.codespar.dev/v1/webhook-endpoints/{id}/dlq \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/webhook-endpoints/{id}/dlq HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/webhook-endpoints/{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/webhook-endpoints/{id}/dlq", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/webhook-endpoints/{id}/dlq", {
path: {
id: "webhookendpoint_0000000000000000"
}
});{
"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/webhook-endpoints/{id}/rotate-secret
https://api.codespar.dev/v1/webhook-endpoints/{id}/rotate-secretMint a new signing secret for a webhook endpoint
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Endpoint id, trg_ followed by a 16 character nanoid, as minted by the create call. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | No webhook endpoint with that id in the caller's organization and project. |
503 | object | The new secret could not be persisted, so no rotation occurred and the previous secret is still in force. Retriable. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
secret | string | yes | The 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_enabled | true | yes | — |
trigger_id | string | yes | — |
curl -X POST https://api.codespar.dev/v1/webhook-endpoints/{id}/rotate-secret \
-H "Authorization: Bearer $CODESPAR_API_KEY"POST /v1/webhook-endpoints/{id}/rotate-secret HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.post(
"https://api.codespar.dev/v1/webhook-endpoints/{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/webhook-endpoints/{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/webhook-endpoints/{id}/rotate-secret", {
path: {
id: "webhookendpoint_0000000000000000"
}
});{
"trigger_id": "trg_0000000000000000",
"signing_enabled": true,
"secret": "string"
}POST /v1/webhook-endpoints/{id}/test-fire
https://api.codespar.dev/v1/webhook-endpoints/{id}/test-fireDeliver a synthetic event to a webhook endpoint
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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Endpoint id, trg_ followed by a 16 character nanoid, as minted by the create call. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
event_type | string | no | Dot separated lowercase, the same shape as a subscription's event. When absent, the synthetic event goes out as trigger.test_fire. |
payload | object | no | EXTRA 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
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | The body did not match the schema. |
404 | object | No webhook endpoint with that id in the caller's organization and project. |
409 | object | The 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
| Field | Type | Required | Description |
|---|---|---|---|
delivery_id | string,null | yes | Null when the dispatch never got as far as recording an attempt. |
error | string,null | yes | Why the attempt failed, in the same form as the delivery history. |
event_id | string | yes | The synthetic event, persisted so the delivery has a real target. |
response_status | integer,null | yes | The HTTP status the subscriber answered, or null when there was no response. |
status | string | yes | The outcome of the attempt, as the dispatcher wrote it. |
trigger_id | string | yes | — |
curl -X POST https://api.codespar.dev/v1/webhook-endpoints/{id}/test-fire \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_type": "string",
"payload": {}
}'POST /v1/webhook-endpoints/{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/webhook-endpoints/{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/webhook-endpoints/{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/webhook-endpoints/{id}/test-fire", {
path: {
id: "webhookendpoint_0000000000000000"
},
body: {
event_type: "string",
payload: {}
}
});{
"trigger_id": "trg_0000000000000000",
"event_id": "event_0000000000000000",
"delivery_id": "delivery_0000000000000000",
"status": "string",
"response_status": 0,
"error": "string"
}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.
Webhook Deliveries
1 operation under /v1/webhook-deliveries (GET): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.