---
title: "Create a webhook subscription"
method: POST
path: "/webhooks"
tags: ["Webhooks"]
---

# Create a webhook subscription

`POST /webhooks`

## Request

- Base URL: `https://inkseal.net/api/v1`
- URL: `https://inkseal.net/api/v1/webhooks`
- Auth: HTTP bearer

Subscribe an HTTPS endpoint to workspace events.

**The signing secret is returned exactly once, in this response.** It is stored encrypted with AES-256-GCM and is never returned again by any endpoint, including `GET /webhooks/{webhookId}`. If it is lost, delete the subscription and create a new one. The secret is 32 random bytes rendered as 64 hex characters.

**Verifying a delivery.** Each delivery arrives as a POST carrying `X-InkSeal-Signature: v1=<hex>`, `X-InkSeal-Timestamp: <unix seconds>`, and `X-InkSeal-Event-Id`. The signature is HMAC-SHA256 over the exact string `<timestamp>.<raw request body>`, keyed by the secret, hex-encoded. Recompute it over the unparsed body, compare in constant time, and reject a timestamp that is too far from now. Delivery is at-least-once, so deduplicate on `X-InkSeal-Event-Id`, which is derived from the state transition and is stable across automatic retries and manual ones.

**Target URL restrictions.** The URL must be `https://`; anything else is refused. It is then resolved through DNS and checked, and the request is refused with 400 `invalid_url` when the host is `localhost`, `127.0.0.1`, or `::1`, when it fails to resolve at all, or when any resolved address falls in a private or reserved range: IPv4 `10.0.0.0/8`, `127.0.0.0/8`, `0.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`, and IPv6 `::1`, `fc00::/7`, and `fe80::/10`. The same check runs again at delivery time, so a hostname that is later repointed at a private address stops being delivered to rather than being used as an internal proxy.

`events` may be an explicit list of event names, or `null` to subscribe to every event including future ones. A workspace holds at most 5 subscriptions; exceeding that answers 409 `limit_exceeded`.

Accepts an `Idempotency-Key`. A retry with the same key replays the original response, including the one-time secret, instead of creating a second subscription.

## Headers

- `Idempotency-Key` string

## Request body

- WebhookSubscriptionCreateRequest: A new subscription.
  - `url` string, uri, required: The endpoint to POST deliveries to. Must be `https://`. Rejected with 400 `invalid_url` when it is localhost, fails to resolve, or resolves to a private or reserved address.
  - `events` WebhookSubscriptionEventType[], nullable, required: The events to subscribe to, at least one and each named at most once, or null to subscribe to every event including ones added later.

## Response `201`

The subscription, plus the only copy of its signing secret that will ever be returned.

- object
  - `data` WebhookSubscriptionCreated, required: The new subscription, plus the only copy of its signing secret that will ever be returned.
    - `id` string, required
    - `url` string, uri, required
    - `events` WebhookSubscriptionEventType[], nullable, required
    - `secret` string, required: **The signing secret, returned once and never again.** It is stored encrypted and no endpoint returns it a second time; if it is lost, delete the subscription and create a new one. 32 random bytes as 64 hex characters. Use it to verify every delivery: recompute `HMAC-SHA256(secret, "<X-InkSeal-Timestamp>.<raw body>")`, hex-encode it, and compare in constant time against the hex after `v1=` in `X-InkSeal-Signature`. The example below is redacted and will verify nothing.

## Other responses

- `400`: The request is malformed or the resource is in the wrong state for a synchronous check. Codes: `validation_error`, `invalid_body`, `invalid_parameter`, `invalid_idempotency_key`, `invalid_event_id`, `invalid_file`, `invalid_url`, `invalid_status`, `key_mismatch`, `no_signers`, `not_completed`, `envelope_exists`, `document_not_ready`, `upload_failed`.
- `401`: The credential is missing, malformed, unknown, expired, revoked, or past its rotation grace window. Code: `unauthorized`. Every authentication failure is deliberately indistinguishable.
- `403`: The credential is valid but may not do this. Codes: `insufficient_scope` (the route's scopes are not all held; `details` names `requiredScopes` and `missingScopes`), `forbidden` (the capability check on the resolved resource failed), `scope_escalation` (a grant wider than the caller's own was requested).
- `409`: The resource's state forbids this transition, or another request owns the work. Codes: `invalid_state`, `invalid_status`, `credential_state`, `limit_exceeded`, `envelope_missing`, `not_retryable`, `owner_unavailable`, `workspace_unavailable`, `detection_not_completed`, `document_in_use`, `version_conflict`, `document_processing`, `document_processing_failed`, `normalized_pdf_unavailable`, `pages_not_rendered`, `document_not_ready`, `idempotency_in_progress` (which also carries `Retry-After`), `payment_replayed` (a USDC payment signature presented twice), `payment_pending` (a USDC settlement whose outcome is not yet known).
- `422`: The request parsed but its content is not acceptable. Codes: `validation_error` (envelope configuration, with an `issues` array in `details`), `invalid_envelope`, `envelope_not_ready`, `document_page_limit`, `idempotency_key_reuse`.
- `429`: A limit was reached. Codes: `rate_limited` (the credential's per-bucket HTTP limit, or the per-workspace detection throttle), `workspace_limit_exceeded` (a workspace content or send limit; `details.limit` names it), `daily_budget_exceeded` (the workspace's daily detection budget). The four rate-limit headers are sent on this response and on no successful one, so a client cannot read its remaining budget without being refused. Retrying after `Retry-After` is always safe: a rate-limit refusal happens before the handler runs.
- `503`: A dependency the request needs is unreachable and nothing was changed. Codes: `queue_unavailable` (a background job could not be enqueued; retry the call), `billing_unavailable` (Stripe would not open a checkout session; nothing was charged, retry the call), `service_unavailable` (the rate-limit store is down, so the request was refused before running).

---

[API](https://skmtc.dev/inkseal/apis/inkseal-api.md) · [All operations](https://skmtc.dev/inkseal/apis/inkseal-api/llms.txt) · [OpenAPI document](https://skmtc.dev/inkseal/apis/inkseal-api/revisions/c440c981d578?raw)
