Webhook Management API
Create, configure, verify and monitor webhook subscriptions over the v1 API.
Everything the Events page in the app does to a webhook subscription is available over the v1 API: create and update subscriptions, verify the endpoint, pause and resume delivery, rotate the signing secret, read the delivery log, read aggregated metrics, and re-queue failed deliveries.
For the payload shapes your endpoint receives, and for signature verification, see Webhook Setup. For the same operations from a terminal, see the limai webhooks command group.
Base Path and Authentication
Every route lives under the project that owns the subscription:
https://app.limai.io/api/v1/projects/{projectId}/webhooksAll routes take a Bearer token:
Authorization: Bearer YOUR_API_TOKENAn API token inherits the role of the user who created it, and every webhook route -- including the read-only ones -- requires the EDIT_MODELS permission. That means OWNER and DEVELOPER only. A token belonging to a USER gets 403 on GET just as it does on DELETE; there is no read-only tier for webhooks. A project-scoped token can only reach its own project.
The Webhook Object
{
"id": "whk_abc123",
"projectId": "proj_abc123",
"url": "https://example.com/hooks/limai",
"events": [
"DOCUMENT_EXTRACTED",
"DOCUMENT_EXTRACTION_FAILED"
],
"isActive": true,
"isVerified": false,
"createdAt": "2026-08-26T09:00:00.000Z",
"updatedAt": "2026-08-26T09:00:00.000Z",
"deployments": [
{
"id": "dep_123abc",
"name": "Invoices",
"type": "MODEL"
}
],
"agents": [],
"classifiers": [],
"splitters": []
}| Field | Type | Description |
|---|---|---|
id | string | Subscription identifier. Used as {webhookId} in every other route. |
projectId | string | The project that owns the subscription. |
url | string | The HTTPS endpoint deliveries are POSTed to. |
events | string[] | The subscribed event types. See Event Types. |
isActive | boolean | Whether the subscription is enabled. Toggled by pause / resume or by isActive on update. |
isVerified | boolean | Whether the endpoint has passed the verification challenge. |
createdAt | ISO 8601 | Creation time. |
updatedAt | ISO 8601 | Last modification time. |
deployments | object[] | Deployment routes, each with id, name and a type of MODEL or PRODUCTION. |
agents | object[] | Agent routes, each with id and name. |
classifiers | object[] | Classifier routes, each with id and name. |
splitters | object[] | Splitter routes, each with id and name. |
A subscription only receives deliveries when isActive and isVerified are both true. Creating a subscription sets isVerified to false; so does changing its url, and so does rotating its secret. After any of those, verify again or nothing will be delivered.
secretKey is not part of this object. It is returned exactly twice in a subscription's life: on create and on rotate secret. Store it when you see it -- no route reads it back.
Routes and Route IDs
A subscription is routed by the deployments, agents, classifiers and splitters it listens to. Which family a given event needs is described in Routing Families; the API enforces the same rule, so a subscription that selects a document event without a deployment route is rejected with 400.
Route ID arrays behave the same way everywhere:
- Each array holds at most 100 IDs, and the IDs within one array must be unique.
- Every ID must belong to this project. Classifiers are the one exception: a global classifier in the same organization is accepted.
- On update, a supplied array replaces that family's routes wholesale. Omitting the array leaves that family untouched; sending an empty array clears it.
List Webhooks
/api/v1/projects/{projectId}/webhooksReturns every webhook subscription in the project, newest first.
Parameters
| Name | Type | In | Required | Description |
|---|---|---|---|---|
projectId | string | path | Yes | The project ID |
Request
const res = await fetch(
"https://app.limai.io/api/v1/projects/proj_abc123/webhooks",
{
headers: {
Authorization: "Bearer YOUR_API_TOKEN",
},
}
);
const { webhooks } = await res.json();Response
{
"webhooks": [
{
"id": "whk_abc123",
"projectId": "proj_abc123",
"url": "https://example.com/hooks/limai",
"events": [
"DOCUMENT_EXTRACTED"
],
"isActive": true,
"isVerified": true,
"createdAt": "2026-08-26T09:00:00.000Z",
"updatedAt": "2026-08-26T09:04:00.000Z",
"deployments": [
{
"id": "dep_123abc",
"name": "Invoices",
"type": "MODEL"
}
],
"agents": [],
"classifiers": [],
"splitters": []
}
]
}Create a Webhook
/api/v1/projects/{projectId}/webhooksCreates a subscription and returns it with its signing secret. This is one of only two responses that ever carries secretKey.
Every field is optional, which lets you create a draft and fill it in later. The subscription is created with isActive: true only when it is fully configured -- a URL, at least one event, and at least one route. Anything less is created inactive. isVerified is always false on create.
Parameters
| Name | Type | In | Required | Description |
|---|---|---|---|---|
projectId | string | path | Yes | The project ID |
url | string | body | No | HTTPS endpoint to deliver to. Must be HTTPS and publicly resolvable |
events | string[] | body | No | Event types to subscribe to |
deploymentIds | string[] | body | No | Deployment routes. Max 100, unique, must belong to this project |
agentIds | string[] | body | No | Agent routes. Max 100, unique, must belong to this project |
classifierIds | string[] | body | No | Classifier routes. Max 100, unique. Project classifiers and global classifiers in the same organization |
splitterIds | string[] | body | No | Splitter routes. Max 100, unique, must belong to this project |
Request
const res = await fetch(
"https://app.limai.io/api/v1/projects/proj_abc123/webhooks",
{
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_TOKEN",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://example.com/hooks/limai",
events: ["DOCUMENT_EXTRACTED", "DOCUMENT_EXTRACTION_FAILED"],
deploymentIds: ["dep_123abc"],
}),
}
);
const webhook = await res.json();
storeSecret(webhook.secretKey);Response
{
"id": "whk_abc123",
"projectId": "proj_abc123",
"url": "https://example.com/hooks/limai",
"events": [
"DOCUMENT_EXTRACTED",
"DOCUMENT_EXTRACTION_FAILED"
],
"isActive": true,
"isVerified": false,
"createdAt": "2026-08-26T09:00:00.000Z",
"updatedAt": "2026-08-26T09:00:00.000Z",
"deployments": [
{
"id": "dep_123abc",
"name": "Invoices",
"type": "MODEL"
}
],
"agents": [],
"classifiers": [],
"splitters": [],
"secretKey": "9f2c4b6d8e0a1c3e5f7a9b1d3f5a7c9e1b3d5f7a9c1e3f5a7b9d1f3a5c7e9b1d"
}Errors
| Status | When |
|---|---|
400 | URL is not HTTPS, is unparseable, or resolves to a blocked address; a route ID does not belong to the project; a route array exceeds 100 IDs or repeats one; an event family has no route of its kind |
403 | The token's role is not OWNER or DEVELOPER, or the token cannot reach this project |
404 | The project does not exist or is not visible to the token |
Get a Webhook
/api/v1/projects/{projectId}/webhooks/{webhookId}Returns one subscription. No secret.
Parameters
| Name | Type | In | Required | Description |
|---|---|---|---|---|
projectId | string | path | Yes | The project ID |
webhookId | string | path | Yes | The webhook subscription ID |
Request
curl "https://app.limai.io/api/v1/projects/proj_abc123/webhooks/whk_abc123" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"id": "whk_abc123",
"projectId": "proj_abc123",
"url": "https://example.com/hooks/limai",
"events": [
"DOCUMENT_EXTRACTED"
],
"isActive": true,
"isVerified": true,
"createdAt": "2026-08-26T09:00:00.000Z",
"updatedAt": "2026-08-26T09:04:00.000Z",
"deployments": [
{
"id": "dep_123abc",
"name": "Invoices",
"type": "MODEL"
}
],
"agents": [],
"classifiers": [],
"splitters": []
}Update a Webhook
/api/v1/projects/{projectId}/webhooks/{webhookId}Updates the fields you send and leaves the rest alone. At least one field is required -- an empty body is a 400.
Changing url sets isVerified back to false. Deliveries stop until you verify the new URL.
Parameters
| Name | Type | In | Required | Description |
|---|---|---|---|---|
projectId | string | path | Yes | The project ID |
webhookId | string | path | Yes | The webhook subscription ID |
url | string | body | No | New HTTPS endpoint. Resets isVerified to false |
events | string[] | body | No | Replaces the subscribed event types |
isActive | boolean | body | No | Enable or disable delivery. Same effect as pause / resume |
deploymentIds | string[] | body | No | Replaces the deployment routes. Max 100, unique |
agentIds | string[] | body | No | Replaces the agent routes. Max 100, unique |
classifierIds | string[] | body | No | Replaces the classifier routes. Max 100, unique |
splitterIds | string[] | body | No | Replaces the splitter routes. Max 100, unique |
Request
await fetch(
"https://app.limai.io/api/v1/projects/proj_abc123/webhooks/whk_abc123",
{
method: "PATCH",
headers: {
Authorization: "Bearer YOUR_API_TOKEN",
"Content-Type": "application/json",
},
body: JSON.stringify({
events: ["DOCUMENT_EXTRACTED", "DOCUMENT_VALIDATED"],
deploymentIds: ["dep_123abc", "dep_456def"],
}),
}
);Response
{
"id": "whk_abc123",
"projectId": "proj_abc123",
"url": "https://example.com/hooks/limai",
"events": [
"DOCUMENT_EXTRACTED",
"DOCUMENT_VALIDATED"
],
"isActive": true,
"isVerified": true,
"createdAt": "2026-08-26T09:00:00.000Z",
"updatedAt": "2026-08-26T10:15:00.000Z",
"deployments": [
{
"id": "dep_123abc",
"name": "Invoices",
"type": "MODEL"
},
{
"id": "dep_456def",
"name": "Receipts",
"type": "PRODUCTION"
}
],
"agents": [],
"classifiers": [],
"splitters": []
}Delete a Webhook
/api/v1/projects/{projectId}/webhooks/{webhookId}Deletes the subscription and its routes. Returns 204 with no body. This is not reversible; to stop delivery without losing the configuration, use pause.
Request
curl -X DELETE "https://app.limai.io/api/v1/projects/proj_abc123/webhooks/whk_abc123" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
204 No ContentPause a Webhook
/api/v1/projects/{projectId}/webhooks/{webhookId}/pauseSets isActive to false. Routes, events, secret and verification state are untouched, so resuming needs no re-verification. Returns the updated Webhook object.
Request
curl -X POST "https://app.limai.io/api/v1/projects/proj_abc123/webhooks/whk_abc123/pause" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"id": "whk_abc123",
"projectId": "proj_abc123",
"url": "https://example.com/hooks/limai",
"events": [
"DOCUMENT_EXTRACTED"
],
"isActive": false,
"isVerified": true,
"createdAt": "2026-08-26T09:00:00.000Z",
"updatedAt": "2026-08-26T11:00:00.000Z",
"deployments": [
{
"id": "dep_123abc",
"name": "Invoices",
"type": "MODEL"
}
],
"agents": [],
"classifiers": [],
"splitters": []
}Resume a Webhook
/api/v1/projects/{projectId}/webhooks/{webhookId}/resumeSets isActive to true. Delivery only actually restarts if the subscription is also verified.
Request
curl -X POST "https://app.limai.io/api/v1/projects/proj_abc123/webhooks/whk_abc123/resume" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"id": "whk_abc123",
"projectId": "proj_abc123",
"url": "https://example.com/hooks/limai",
"events": [
"DOCUMENT_EXTRACTED"
],
"isActive": true,
"isVerified": true,
"createdAt": "2026-08-26T09:00:00.000Z",
"updatedAt": "2026-08-26T11:05:00.000Z",
"deployments": [
{
"id": "dep_123abc",
"name": "Invoices",
"type": "MODEL"
}
],
"agents": [],
"classifiers": [],
"splitters": []
}Verify a Webhook
/api/v1/projects/{projectId}/webhooks/{webhookId}/verifySends a signed VERIFICATION event to the subscription's URL. Your endpoint must echo the challenge value back in its response body. On success isVerified becomes true; on failure it is left alone.
This route's response shape differs from the rest: it is success plus message, not a Webhook object.
Verification pings are not written to the delivery log. They never appear in List Deliveries.
Request
curl -X POST "https://app.limai.io/api/v1/projects/proj_abc123/webhooks/whk_abc123/verify" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"success": true,
"message": "Webhook URL verified successfully"
}Endpoint Unreachable
When your endpoint refuses the challenge, times out, or answers with the wrong body, the route answers 502 with the same two fields:
{
"success": false,
"message": "Webhook endpoint responded with status 500"
}A 400 here means something else: the subscription's URL or secret changed while the challenge was in flight (Webhook URL or secret changed during verification; verify again). Re-read the subscription and verify again.
Rotate the Signing Secret
/api/v1/projects/{projectId}/webhooks/{webhookId}/rotate-secretGenerates a new signing secret and returns the subscription with secretKey. Every event enqueued after the rotation is signed with the new secret.
Rotation does not reach into the delivery queue. A job captures the URL and secret at enqueue time and keeps them, so deliveries that were already queued or mid-retry when you rotated are still signed with the old secret -- and so is any retry of a delivery enqueued before the rotation. Automatic retries drain within a few minutes (five attempts, 10 s exponential backoff), but a retained payload can be retried by hand for about 7 days. Keep accepting the old secret alongside the new one for as long as you may retry such deliveries, rather than dropping it the moment you rotate.
Rotation also resets isVerified to false, because the challenge is signed with the secret. Verify again afterwards or delivery stays stopped.
Request
curl -X POST "https://app.limai.io/api/v1/projects/proj_abc123/webhooks/whk_abc123/rotate-secret" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"id": "whk_abc123",
"projectId": "proj_abc123",
"url": "https://example.com/hooks/limai",
"events": [
"DOCUMENT_EXTRACTED"
],
"isActive": true,
"isVerified": false,
"createdAt": "2026-08-26T09:00:00.000Z",
"updatedAt": "2026-08-26T12:00:00.000Z",
"deployments": [
{
"id": "dep_123abc",
"name": "Invoices",
"type": "MODEL"
}
],
"agents": [],
"classifiers": [],
"splitters": [],
"secretKey": "1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3f5a7b9d1f3a5c7e9b1d3f"
}List Deliveries
/api/v1/projects/{projectId}/webhooks/{webhookId}/deliveriesReturns the delivery log for one subscription, newest first, with a total for pagination.
Parameters
| Name | Type | In | Required | Description |
|---|---|---|---|---|
projectId | string | path | Yes | The project ID |
webhookId | string | path | Yes | The webhook subscription ID |
status | string | query | No | One of success, failed, dead_lettered, retrying, pending |
eventType | string | query | No | Filter to a single event type |
deploymentId | string | query | No | Only deliveries routed by this deployment |
agentId | string | query | No | Only deliveries routed by this agent |
classifierId | string | query | No | Only deliveries routed by this classifier |
splitterId | string | query | No | Only deliveries routed by this splitter |
limit | integer | query | No | Page size, 1 to 200(default: 50) |
offset | integer | query | No | Rows to skip(default: 0) |
Request
const params = new URLSearchParams({ status: "failed", limit: "50" });
const res = await fetch(
`https://app.limai.io/api/v1/projects/proj_abc123/webhooks/whk_abc123/deliveries?${params}`,
{
headers: {
Authorization: "Bearer YOUR_API_TOKEN",
},
}
);
const { deliveries, total } = await res.json();Response
{
"deliveries": [
{
"eventId": "evt_1756199524000_k3f9x2mq7ab",
"subscriptionId": "whk_abc123",
"eventType": "DOCUMENT_EXTRACTED",
"timestamp": "2026-08-26T09:12:04.000Z",
"url": "https://example.com/hooks/limai",
"status": "failed",
"retryCount": 4,
"routeFamily": "deployment",
"responseCode": 500,
"responseBody": "internal error",
"errorType": "ENDPOINT_ERROR",
"durationMs": 812,
"deploymentId": "dep_123abc",
"deploymentName": "Invoices"
}
],
"total": 137,
"limit": 50,
"offset": 0
}The Delivery Object
| Field | Type | Description |
|---|---|---|
eventId | string | The event identifier, the same one your endpoint received. Use it with retry. |
subscriptionId | string | The subscription this delivery belongs to. |
eventType | string | The event type that was delivered. |
timestamp | ISO 8601 | When the event happened, not when the attempt was made. |
url | string | The URL the attempt was made against, as it was at the time. |
status | string | One of the five statuses below. |
retryCount | integer | Attempts after the first. A delivery that succeeded first try reads 0. |
routeFamily | string | deployment, agent, classifier or splitter. Absent when the delivery carried no route. |
responseCode | integer | HTTP status your endpoint returned. Absent for transport errors and blocked URLs. |
responseBody | string | First 500 characters of your endpoint's response body, when there was one. |
errorType | string | ENDPOINT_REJECTED, ENDPOINT_ERROR, TRANSPORT_ERROR, URL_BLOCKED, TERMINAL_REJECTION or RETRIES_EXHAUSTED. Absent on success. |
durationMs | integer | How long the attempt took. |
deploymentId, deploymentName | string | Present when routeFamily is deployment. |
agentId, agentName | string | Present when routeFamily is agent. |
classifierId, classifierName | string | Present when routeFamily is classifier. |
splitterId, splitterName | string | Present when routeFamily is splitter. |
Optional fields are omitted, not set to null.
Delivery Statuses
| Status | Meaning |
|---|---|
success | Your endpoint answered 2xx. Terminal. |
pending | Queued, not attempted yet. |
retrying | Attempted at least once, failed, and a further attempt is scheduled. |
failed | No further attempt will be made automatically -- either the failure was terminal (a 4xx, or a blocked URL) or all 5 attempts were spent. Retryable by hand. |
dead_lettered | The job reached the dead-letter queue. Retryable by hand while the payload is still retained. |
failed and dead_lettered describe the same delivery seen from two sides: the worker marks the last attempt failed, then the dead-letter hook marks the job dead_lettered. Both count as failures in metrics, and both are candidates for retry.
The log only holds events enqueued from 2026-08-26 onwards, when per-attempt recording shipped. See What the Delivery Log Does Not Tell You.
Get Metrics
/api/v1/projects/{projectId}/webhooks/{webhookId}/metricsAggregated delivery counts for one subscription over a time window, broken down per route and bucketed into a time series.
Parameters
| Name | Type | In | Required | Description |
|---|---|---|---|---|
projectId | string | path | Yes | The project ID |
webhookId | string | path | Yes | The webhook subscription ID |
window | string | query | No | Time window: 24h, 7d or 30d(default: 30d) |
bucket | string | query | No | Series granularity: hour or day. Defaults to hour for a 24h window and day otherwise |
Request
curl "https://app.limai.io/api/v1/projects/proj_abc123/webhooks/whk_abc123/metrics?window=7d&bucket=day" \
-H "Authorization: Bearer YOUR_API_TOKEN"Response
{
"organizationId": "org_xyz789",
"totalEvents": 420,
"successfulEvents": 402,
"failedEvents": 15,
"pendingEvents": 3,
"successRate": 95.71,
"averageResponseTime": 214.5,
"perRoute": [
{
"routeFamily": "deployment",
"routeId": "dep_123abc",
"routeName": "Invoices",
"totalEvents": 380,
"successfulEvents": 370,
"failedEvents": 8,
"pendingEvents": 2,
"successRate": 97.37
},
{
"routeFamily": "classifier",
"routeId": "cls_456def",
"routeName": "Inbound router",
"totalEvents": 40,
"successfulEvents": 32,
"failedEvents": 7,
"pendingEvents": 1,
"successRate": 80
}
],
"series": [
{
"bucket": "2026-08-25T00:00:00.000Z",
"successful": 210,
"failed": 4,
"pending": 0
},
{
"bucket": "2026-08-26T00:00:00.000Z",
"successful": 192,
"failed": 11,
"pending": 3
}
],
"window": "7d",
"bucket": "day"
}| Field | Type | Description |
|---|---|---|
organizationId | string | The organization the project belongs to. |
totalEvents | integer | Deliveries recorded in the window. |
successfulEvents | integer | Deliveries with status success. |
failedEvents | integer | failed and dead_lettered together. |
pendingEvents | integer | pending and retrying together. |
successRate | number | Percentage, 0 to 100. 0 when there were no deliveries. |
averageResponseTime | number | Mean durationMs across successful deliveries. Omitted when nothing succeeded. |
perRoute | object[] | One entry per route that saw traffic, busiest first. |
series | object[] | Contiguous buckets covering the whole window -- quiet buckets are present with zeros. |
window | string | The window actually applied. |
bucket | string | The granularity actually applied. |
Each perRoute entry carries routeFamily (deployment, agent, classifier or splitter), routeId, routeName, its own totalEvents, successfulEvents, failedEvents and pendingEvents, and a successRate. routeName falls back to the ID if the route has since been deleted.
perRoute replaces the older perDeployment breakdown. Classification and splitting events have no deployment to key off, so a deployment-only breakdown could not represent them.
Retry Failed Deliveries
/api/v1/projects/{projectId}/webhooks/{webhookId}/retryRe-queues deliveries that ended failed or dead_lettered. A retry replays the job as it was enqueued: the delivery goes to the URL and is signed with the secret captured at enqueue time, not the subscription's current ones. Changing the URL or rotating the secret does not redirect or re-sign a retried delivery -- re-drive the event from your own side if you need that.
Parameters
| Name | Type | In | Required | Description |
|---|---|---|---|---|
projectId | string | path | Yes | The project ID |
webhookId | string | path | Yes | The webhook subscription ID |
eventIds | string[] | body | Yes | Event IDs to retry. Between 1 and 100 |
Request
const res = await fetch(
"https://app.limai.io/api/v1/projects/proj_abc123/webhooks/whk_abc123/retry",
{
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_TOKEN",
"Content-Type": "application/json",
},
body: JSON.stringify({
eventIds: ["evt_1756199524000_k3f9x2mq7ab"],
}),
}
);
const { retriedCount, skipped } = await res.json();Response
{
"retriedCount": 1,
"retried": [
{
"eventId": "evt_1756199524000_k3f9x2mq7ab",
"url": "https://example.com/hooks/limai"
}
],
"skipped": [
{
"eventId": "evt_1756199111000_p8q2r4s6t8u",
"url": "https://example.com/hooks/limai",
"reason": "payload_unavailable"
}
],
"message": "Re-queued 1 of 2 webhook deliveries; the rest are already in flight, or their original payload is no longer retained"
}A 200 here means the request reached the delivery queue, not that anything was re-sent. Whether a given delivery actually went back on the queue is reported per delivery: everything in retried was re-queued, everything in skipped was not, with a reason. A 500 means the queue itself could not be reached.
reason | Meaning |
|---|---|
already_delivered | The queued job completed successfully. There is nothing to re-send. |
in_flight | The job is waiting, active or delayed right now. A retry would duplicate it. |
payload_unavailable | The original payload is no longer retained, so the delivery cannot be reconstructed. See below. |
Asking to retry an event ID that is not a failed delivery of this subscription is not an error either -- it is simply not among the candidates, and the response reads No failed webhook deliveries to retry.
How Far Back Retry Reaches
Retry replays the queued job's retained payload, not a copy in the database. The delivery log therefore outlives the ability to retry from it: a row can read failed long after its payload is gone.
- A failed delivery is retained on the delivery queue for 7 days, and only the most recent 5000 failures are kept. Whichever limit is hit first wins, so a high-volume project loses reach sooner than 7 days.
- A dead-lettered delivery is retained on the dead-letter queue, which is swept on the same 7 day retention. The dead-letter scan also stops after 5000 entries per queue state.
Past that, the delivery still appears in the log, but retry answers payload_unavailable. Re-drive it from your own side using the eventId and the source data instead.
Note that retriedCount counts deliveries put back on the queue, not deliveries that ultimately succeed. A re-queued delivery also keeps its attempt counter rather than starting a fresh set of five, so one that had already exhausted its attempts gets a single further attempt before it dead-letters again. See Retry Policy.
Error Responses
Every route on this page shares one error shape, an object with a single error string. The exception is verify, which reports endpoint failures as success: false plus a message, with status 502.
| Status | Body | Cause |
|---|---|---|
400 | error | A domain rule was broken -- bad URL, a route that is not in this project, an event family with no route |
400 | error plus details | The request body or query string failed schema validation. details carries the flattened field errors |
401 | Authentication required | Missing or unrecognised API token |
403 | Unauthorized: Only OWNER and DEVELOPER roles can perform this operation | The token's role lacks EDIT_MODELS |
403 | Unauthorized: No access to this project | The token cannot reach this project |
404 | Webhook not found | The subscription, or the project, does not exist for this token |
502 | success: false plus message | Verify only: your endpoint refused the challenge, timed out, or answered with the wrong body |
500 | Internal server error | Unexpected failure |
404 reads Webhook not found for a missing project too -- it does not distinguish the two.