← Hook Contract
API
Tokens

Run Hook Contract from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page lints every event name, reads the envelope and the signing scheme of one real delivery against Standard Webhooks, works out what the retry schedule really does, checks the delivery contract and the debugging surface, and settles the build rung and the retry rung, then hands it all to the run as facts. The test secret the page verifies signatures with is never part of the input. The run writes the catalog or the contract; the page then holds it to its own facts (hookreply.js): every listed event covered once with a past-tense resource.action name, an example signed the standard way; the settled build rung, at-least-once, a schedule that grows and spans the floor, 4xx dead-lettered, the three subscription states, the five contract facts in the docs, and never exactly-once.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"hook-contract"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required key (task, platform, delivery, facts) is missing. The page always sends all five of task, platform, delivery, carry, facts; carry is an empty string for catalog.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes, chosen by task, in the order the work runs - the events, then the promise:

A missing or unknown task is answered as catalog. Both lanes send the same five fields. The facts are the page's checks, one row per line, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the catalog's names into carry for the contract lane.

The input

FieldRequiredWhat
taskyescatalog or contract. If it is missing or neither, do catalog and set "task": "catalog".
platformyesthe platform's name, what it does, the consumers, retrofit or greenfield, the scale, whether webhooks are the product, whether event data may transit a third party, the ship date, payments-grade, the retry schedule and delivery settings as given, the debugging surface, and the event list; "not given" means not given.
deliveryyesone real delivery as pasted: headers, a blank line, the raw body; "(not pasted)" means not pasted.
carrysend it (empty for catalog)contract task only: the catalog's event names, when the catalog task ran first; may be empty.
factsyesthe page's checks, one line each as area | id | status | evidence, the name lint, the signing scheme, and for the contract the schedule as read and the rungs the page settled. A line starting "must:" applies to your reply.
retry_notenooptional; if present, your previous reply was unusable - follow it.

The rules the model applies

From the source skill, @samber/webhook-platform-design - the only standard the run applies:

House rules

The replies

catalog

Write the event catalog and the envelope.

  1. summary - one sentence, at most 40 words: what is wrong with the events today and the first fix.
  2. convention - pattern (one sentence: the naming rule), bubbling (one sentence: whether subresource events fire on their parent).
  3. events - one object per event in the catalog, every listed event covered once: name (resource.action, lowercase, dot-delimited, [a-z0-9_.], past tense), replaces (the listed name it renames, exactly as listed, or "" when the name is unchanged or new), description (one sentence: what happened), payload (thin or full - thin by default, and for anything sensitive or money-moving; full where consumers, such as no-code tools, need the fields in the delivery itself), fields (the payload's main fields, as short strings).
  4. envelope - placement (headers or body), payload (thin or full), versioning (endpoint-pinned or event-versioned), why (one or two sentences).
  5. example - one complete delivery for one of your events: headers (webhook-id, webhook-timestamp as Unix seconds, webhook-signature as v1,<base64>, content-type) and body (the JSON body, with type equal to that event's name).
  6. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "catalog",
  "summary": "one sentence",
  "convention": { "pattern": "one sentence", "bubbling": "one sentence" },
  "events": [ { "name": "invoice.paid", "replaces": "invoice.pay", "description": "An invoice was paid in full.", "payload": "thin", "fields": [ "invoice id", "amount" ] } ],
  "envelope": { "placement": "headers", "payload": "thin", "versioning": "event-versioned", "why": "one or two sentences" },
  "example": { "headers": { "webhook-id": "msg_...", "webhook-timestamp": "1706100000", "webhook-signature": "v1,...", "content-type": "application/json" }, "body": { "type": "invoice.paid", "timestamp": "2024-01-24T12:40:00Z", "data": { } } },
  "caveats": [ ]
}

The page holds it to its own facts (check() in hookreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL; no exactly-once claim; no backticks; every listed event covered exactly once, by name or by replaces; no repeated name; each name resource.action, lowercase [a-z0-9_.], the action in the past tense; replaces only names that were listed; each event described, thin or full, with fields; the naming pattern and the bubbling rule; a known placement, payload and versioning camp; an example with webhook-id, a webhook-timestamp in Unix seconds and a v1, signature, whose body type is one of the catalog's names. A replaces that repeats the name is a note that does not flag.

contract

Write the delivery contract.

  1. summary - one sentence, at most 40 words.
  2. build - choice (managed, self-hosted or in-house, as the facts settle it), why (one sentence).
  3. guarantee - exactly "at-least-once".
  4. ordering - best-effort or fifo-opt-in.
  5. dedup - one sentence: which header or field carries the stable event ID and what the subscriber does with it.
  6. retry - schedule (the seconds to wait before each attempt, the first being 0), span_hours (their sum, in hours), why (one sentence).
  7. timeout_s - the ack timeout in seconds.
  8. non_retriable - the statuses sent straight to the dead-letter queue, as numbers.
  9. retry_after - true when a 429's Retry-After is honoured over the schedule.
  10. dlq - retention_days, triggers (the two triggers, as short strings).
  11. disable - after (when an endpoint is disabled), notify (how the subscriber is told, out-of-band).
  12. lifecycle - states (["active", "paused", "disabled"]), start (where a new subscription starts), resume (where one resumes after an incident), delete_warning (one sentence).
  13. portal - logs, replay, retry and test_events, one sentence each: what the debugging surface should offer - all four are table stakes - with retry and replay described as distinct operations; name in the caveats any of them the platform does not have today.
  14. docs - the subscriber-facing contract as one plain-text string: the guarantee, the ordering, the exact retry schedule, the ack timeout, the dedup header, the signature check with the 5-minute window and the three pitfalls, troubleshooting and testing.
  15. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "contract",
  "summary": "one sentence",
  "build": { "choice": "managed", "why": "one sentence" },
  "guarantee": "at-least-once",
  "ordering": "best-effort",
  "dedup": "one sentence",
  "retry": { "schedule": [ 0, 5, 300, 1800, 7200, 18000, 36000, 36000 ], "span_hours": 27.6, "why": "one sentence" },
  "timeout_s": 10,
  "non_retriable": [ 400, 401, 403, 404, 410, 422 ],
  "retry_after": true,
  "dlq": { "retention_days": 14, "triggers": [ "retry budget exhausted", "a non-retriable status" ] },
  "disable": { "after": "one sentence", "notify": "one sentence" },
  "lifecycle": { "states": [ "active", "paused", "disabled" ], "start": "one sentence", "resume": "one sentence", "delete_warning": "one sentence" },
  "portal": { "logs": "one sentence", "replay": "one sentence", "retry": "one sentence", "test_events": "one sentence" },
  "docs": "the contract as plain text",
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; no URL; no exactly-once claim; no backticks; the build rung the page settled; at-least-once; best-effort or fifo-opt-in ordering; a dedup statement; a schedule of at least four attempts starting at 0, never shrinking, the last wait at least four times the first retry's, spanning at least the floor and at most 3 days, with span_hours that adds up; an ack timeout of 5 to 30 s; 400, 401, 403 and 404 dead-lettered and nothing retriable among them; Retry-After honoured; 7 to 30 days of dead-letter retention on two triggers; when and how an endpoint is disabled; the three states; where a subscription starts and resumes, and the delete warning; logs, retry, replay and test events, with retry and replay described differently; and docs that state at-least-once, the ordering, the ack timeout, webhook-id, the 5-minute window, the raw body, testing and every wait of the schedule. A timeout that differs from the platform's is a note that does not flag.

Worked examples

Three runs from the page's free samples (invented platforms, ids and payloads; no sample carries a secret): the catalog for an invoicing API in production, the delivery contract for the same API carrying that catalog, and the catalog for a no-code forms platform. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the delivery, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.

catalog - An invoicing API in production

The page read it as: subscribers will miss or mistrust deliveries (10 fail, 3 warn, 7 pass); signing: Custom header x-billquent-signature; events: invoice.created, invoice.pay, payment.failed, customer_updated, Subscription.Cancelled; failing checks: names, delimiter, replay, rotation, retries, 4xx, ordering, dlq, replay, tests.

Input (facts is 1899 characters; the first 1399 are shown):

{
 "task": "catalog",
 "platform": "Platform: Billquent\nWhat it does: Invoicing and subscription billing for small SaaS companies; customers sync invoices into their accounting tools from our webhooks.\nConsumers: Typical API developers\nMode: Retrofit - webhooks already ship\nScale: Thousands of endpoints\nWebhooks are: Webhooks are a feature of the product\nEvent data may transit a third-party processor: yes\nHard ship date: no\nPayments-grade: yes\nRetry schedule as given: 1m, 1m, 1m\nAck timeout: 30 s\nOrdering: Not stated\nNon-retriable 4xx retried: yes\nDead-letter retention: none\nAuto-disable and notify: no\nDelivery logs: yes\nReplay: no\nTest events: no\nMultiple active signing secrets: no\nEvents:\n- invoice.created | an invoice was drafted\n- invoice.pay | an invoice was paid\n- payment.failed | a card payment failed\n- customer_updated | a customer's details changed\n- Subscription.Cancelled | a subscription was cancelled",
 "delivery": "POST /webhooks/billquent\nContent-Type: application/json\nX-Billquent-Signature: sha256=3f1c9a0d7e2b4c6a8f5e1d0b9c7a6e4f2d1c0b9a8e7f6d5c4b3a2918f7e6d5c4\n\n{\"id\": \"evt_7K2pQ\", \"type\": \"invoice.pay\", \"created\": 1706100000, \"data\": {\"invoice\": {\"id\": \"in_88Fd\", \"amount_due\": 4900, \"currency\": \"usd\", \"customer\": \"cus_31Xa\", \"status\": \"paid\"}}}",
 "carry": "",
 "facts": "The page's checks (10 fail, 3 warn, 7 pass, 0 n/a):\n- Events | names | fail | invoice.pay - the action \"pay\" is not in the past tense; customer_updated - no resource.action dot; Subscription.Cancelled - mixed case\n- Events | delimiter | fail | mixes . and _\n- Events | described | pass | all described\n- Envelope | type | pass | \"invoice.pay\"\n- Envelope | id | pass | evt_7K2pQ (body)\n- Envelope | timestamp | pass | 1706100000\n- Envelope | size | pass | 186 bytes\n- Signing | scheme | warn | Custom header x-billquent-signature\n- Signing | replay | fail | a body-only signature validates forever - a captured request replays indefinitely\n- Signing | rotation | fail | one active secret forces revoke-then-deploy\n- Delivery | retries | fail | 3 retries over 3 min, linear\n- Delivery | span | warn | 3 min - shorter than an outage can last\n- Delivery | 4xx | fail | 4xx responses are retried until the budget runs out\n- Delivery | timeout | pass | 30 s\n- Delivery | ordering | fail | not stated - subscribers will assume it\n- Delivery | dlq | fail | no dead-letter queue\n- Delivery | disable | warn | no - a dead endpoint keeps burning the retry budget silently\n- Subscriptions and debugging | logs | pass | yes\n- Subscriptions and debugging | replay | fail | no replay\n- Subscriptions and debugging | tests | fail | no test events\nEvent name lint: invoice.created (ok), invoice.pay (the action \"pay\"\n\n[... 500 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "catalog",
 "summary": "Three of the five events break resource.action in the past tense with one dot delimiter, and deliveries carry a custom body-only signature; rename invoice.pay, customer_updated and Subscription.Cancelled first, then sign the Standard [... 14 more characters cut from this display]",
 "convention": {
  "pattern": "Every event is resource.action in lowercase snake_case segments joined by a single dot, the resource a singular noun and the action a past-tense verb, such as invoice.paid.",
  "bubbling": "Events do not bubble: a payment failure fires payment.failed only, and subscribers who track the invoice read the invoice id carried in that payload rather than waiting for an invoice event."
 },
 "events": [
  {
   "name": "invoice.created",
   "replaces": "",
   "description": "A new invoice was drafted for a customer.",
   "payload": "thin",
   "fields": [
    "invoice id",
    "customer id",
    "status",
    "amount_due",
    "currency"
   ]
  },
  {
   "name": "invoice.paid",
   "replaces": "invoice.pay",
   "description": "An invoice was paid in full.",
   "payload": "thin",
   "fields": [
    "invoice id",
    "customer id",
    "amount_paid",
    "currency",
    "paid_at"
   ]
  },
  {
   "name": "payment.failed",
   "replaces": "",
   "description": "A card payment against an invoice was declined or errored.",
   "payload": "thin",
   "fields": [
    "payment id",
    "invoice id",
    "customer id",
    "failure_code",
    "attempt number"
   ]
  },
  {
   "name": "customer.updated",
   "replaces": "customer_updated",
   "description": "A customer's billing details changed.",
   "payload": "thin",
   "fields": [
    "customer id",
    "changed field names",
    "updated_at"
   ]
  },
  {
   "name": "subscription.cancelled",
   "replaces": "Subscription.Cancelled",
   "description": "A subscription was cancelled and will not renew.",
   "payload": "thin",
   "fields": [
    "subscription id",
    "customer id",
    "cancelled_at",
    "ends_at",
    "reason"
   ]
  }
 ],
 "envelope": {
  "placement": "headers",
  "payload": "thin",
  "versioning": "event-versioned",
  "why": "Standard Webhooks headers carry the id, timestamp and signature so verification never parses the body, and thin payloads of identifiers and status keep billing data minimal while it passes through a third-party processor - subscribers [... 219 more characters cut from this display]"
 },
 "example": {
  "headers": {
   "webhook-id": "evt_7K2pQ",
   "webhook-timestamp": "1706100000",
   "webhook-signature": "v1,EXAMPLEonlyNotARealSignatureAAAAAAAAAAAAAAA=",
   "content-type": "application/json"
  },
  "body": {
   "type": "invoice.paid",
   "timestamp": "2024-01-24T12:40:00Z",
   "data": {
    "invoice_id": "in_88Fd",
    "customer_id": "cus_31Xa",
    "amount_paid": 4900,
    "currency": "usd",
    "paid_at": "2024-01-24T12:39:58Z"
   }
  }
 },
 "caveats": [
  "This is a retrofit: send the old names and the X-Billquent-Signature header alongside the new ones for a published migration window, then retire them.",
  "Keep the existing evt_ id as the value of webhook-id so subscribers who already dedup on it keep working."
 ]
}

Cut from this display only (the saved reply and the check use the full text): summary (247 characters, 233 shown); envelope.why (453 characters, 234 shown).

What step 5 prints for it: invoice.created | invoice.paid | payment.failed | customer.updated | subscription.cancelled

The page's check: kept - nothing to flag

contract - An invoicing API in production

The input carries the saved catalog's names (R.laneForm fills carry from the catalog reply), as the page's handoff does. Build rung: managed; retry rung: fixed-day.

Input (facts is 2272 characters; the first 1399 are shown):

{
 "task": "contract",
 "platform": "Platform: Billquent\nWhat it does: Invoicing and subscription billing for small SaaS companies; customers sync invoices into their accounting tools from our webhooks.\nConsumers: Typical API developers\nMode: Retrofit - webhooks already ship\nScale: Thousands of endpoints\nWebhooks are: Webhooks are a feature of the product\nEvent data may transit a third-party processor: yes\nHard ship date: no\nPayments-grade: yes\nRetry schedule as given: 1m, 1m, 1m\nAck timeout: 30 s\nOrdering: Not stated\nNon-retriable 4xx retried: yes\nDead-letter retention: none\nAuto-disable and notify: no\nDelivery logs: yes\nReplay: no\nTest events: no\nMultiple active signing secrets: no\nEvents:\n- invoice.created | an invoice was drafted\n- invoice.pay | an invoice was paid\n- payment.failed | a card payment failed\n- customer_updated | a customer's details changed\n- Subscription.Cancelled | a subscription was cancelled",
 "delivery": "POST /webhooks/billquent\nContent-Type: application/json\nX-Billquent-Signature: sha256=3f1c9a0d7e2b4c6a8f5e1d0b9c7a6e4f2d1c0b9a8e7f6d5c4b3a2918f7e6d5c4\n\n{\"id\": \"evt_7K2pQ\", \"type\": \"invoice.pay\", \"created\": 1706100000, \"data\": {\"invoice\": {\"id\": \"in_88Fd\", \"amount_due\": 4900, \"currency\": \"usd\", \"customer\": \"cus_31Xa\", \"status\": \"paid\"}}}",
 "carry": "invoice.created\ninvoice.paid (was invoice.pay)\npayment.failed\ncustomer.updated (was customer_updated)\nsubscription.cancelled (was Subscription.Cancelled)",
 "facts": "The page's checks (10 fail, 3 warn, 7 pass, 0 n/a):\n- Events | names | fail | invoice.pay - the action \"pay\" is not in the past tense; customer_updated - no resource.action dot; Subscription.Cancelled - mixed case\n- Events | delimiter | fail | mixes . and _\n- Events | described | pass | all described\n- Envelope | type | pass | \"invoice.pay\"\n- Envelope | id | pass | evt_7K2pQ (body)\n- Envelope | timestamp | pass | 1706100000\n- Envelope | size | pass | 186 bytes\n- Signing | scheme | warn | Custom header x-billquent-signature\n- Signing | replay | fail | a body-only signature validates forever - a captured request replays indefinitely\n- Signing | rotation | fail | one active secret forces revoke-then-deploy\n- Delivery | retries | fail | 3 retries over 3 min, linear\n- Delivery | span | warn | 3 min - shorter than an outage can last\n- Delivery | 4xx | fail | 4xx responses are retried until the budget runs out\n- Delivery | timeout | pass | 30 s\n- Delivery | ordering | fail | not stated - subscribers will assume it\n- Delivery | dlq | fail | no dead-letter queue\n- Delivery | disable | warn | no - a dead endpoint keeps burning the retry budget silently\n- Subscriptions and debugging | logs | pass | yes\n- Subscriptions and debugging | replay | fail | no replay\n- Subscriptions and debugging | tests | fail | no test events\nEvent name lint: invoice.created (ok), invoice.pay (the action \"pay\"\n\n[... 873 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "contract",
 "summary": "Today's three one-minute retries, retried 4xx and missing dead-letter queue lose payment events in any real outage; move to a managed dispatcher with an exponential schedule spanning about 27.6 hours and Standard Webhooks signing.",
 "build": {
  "choice": "managed",
  "why": "Webhooks are a feature of Billquent rather than the product, and event data may pass through a third-party processor, so a managed dispatcher delivers retries, dead-lettering, rotation and a portal without building them."
 },
 "guarantee": "at-least-once",
 "ordering": "best-effort",
 "dedup": "The webhook-id header carries the stable event id (the existing evt_ id), identical on every retry; subscribers record each id they have processed and acknowledge a repeat with 2xx without applying it twice.",
 "retry": {
  "schedule": [
   0,
   5,
   300,
   1800,
   7200,
   18000,
   36000,
   36000
  ],
  "span_hours": 27.58,
  "why": "Billing is payments-grade, so eight attempts over about 27.6 hours ride out an overnight outage at a subscriber, where the current 3 minutes of linear retries cannot."
 },
 "timeout_s": 10,
 "non_retriable": [
  400,
  401,
  403,
  404,
  405,
  410,
  413,
  422
 ],
 "retry_after": true,
 "dlq": {
  "retention_days": 30,
  "triggers": [
   "retry budget exhausted after the eighth attempt",
   "a non-retriable 4xx response"
  ]
 },
 "disable": {
  "after": "An endpoint is disabled when every delivery to it has failed for 5 consecutive days.",
  "notify": "The account's technical contact gets an email and the dashboard shows a banner on the endpoint, both when failures begin and when it is disabled."
 },
 "lifecycle": {
  "states": [
   "active",
   "paused",
   "disabled"
  ],
  "start": "A new subscription is active at once and receives only events created after it was saved, with no backfill.",
  "resume": "A paused or disabled endpoint resumes with new events when re-enabled, and the events it missed are recovered from the dead-letter queue by bulk redelivery within the 30 days.",
  "delete_warning": "Deleting a subscription permanently destroys its delivery logs, dead-lettered events and replay state - pause it instead if the endpoint may come back."
 },
 "portal": {
  "logs": "Every attempt is logged with its request headers, raw body, response status, response body and latency, searchable by event id and event type.",
  "replay": "Replay re-ingests an event as a new event with a new webhook-id, for a subscriber who must reprocess data it already acknowledged.",
  "retry": "Retry sends a new attempt of the same event with the same webhook-id, from the log or in bulk from the dead-letter queue, so dedup still recognises it.",
  "test_events": "Send test event fires a signed sample of any catalog event type, such as invoice.paid, to a chosen endpoint and shows the response."
 },
 "docs": "Delivery guarantee: Billquent delivers every event at least once (at-least-once). The same event can arrive more than once, so deduplicate on the webhook-id header: it is stable across every retry of an event. Store the ids you have [... 2787 more characters cut from this display]",
 "caveats": [
  "The ack timeout moves from 30 s to 10 s so a slow handler fails fast and is retried rather than holding a worker; subscribers must respond before processing.",
  "4xx responses other than 408 and 429 stop being retried and go to the dead-letter queue.",
  "Replay, test events, the dead-letter queue and auto-disable are new to Billquent and must ship before these docs are published."
 ]
}

Cut from this display only (the saved reply and the check use the full text): docs (3019 characters, 232 shown).

What step 5 prints for it: now | +5 s | +5 min | +30 min | +2 h | +5 h | +10 h | +10 h

The page's check: kept - nothing to flag

catalog - A no-code forms platform

The page read it as: fixes recommended (0 fail, 2 warn, 19 pass); signing: Standard Webhooks; events: form.submitted, form.updated, response.deleted, file.uploaded, workspace.member_added; failing checks: none.

Input (facts is 1616 characters; the first 1399 are shown):

{
 "task": "catalog",
 "platform": "Platform: Formbrisk Forms\nWhat it does: A form builder for small teams; most webhook consumers are Zapier-style automations, and our customers' form data must stay on our own infrastructure.\nConsumers: No-code workflow tools\nMode: Greenfield - designing before launch\nScale: Thousands of endpoints\nWebhooks are: Webhooks are a feature of the product\nEvent data may transit a third-party processor: no\nHard ship date: no\nPayments-grade: no\nRetry schedule as given: immediately, 5s, 5m, 30m, 2h, 5h, 10h, 10h\nAck timeout: 15 s\nOrdering: Best-effort, no ordering promise\nNon-retriable 4xx retried: no\nDead-letter retention: 14 days\nAuto-disable and notify: yes\nDelivery logs: yes\nReplay: yes\nTest events: yes\nMultiple active signing secrets: yes\nEvents:\n- form.submitted | someone submitted a form\n- form.updated | a form's fields changed\n- response.deleted\n- file.uploaded | a respondent uploaded a file\n- workspace.member_added",
 "delivery": "POST /hooks/formbrisk\nwebhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4W\nwebhook-timestamp: 1706100000123\nwebhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=\ncontent-type: application/json\n\n{\"type\": \"form.submitted\", \"timestamp\": \"2024-01-24T12:40:00Z\", \"data\": {\"form_id\": \"frm_9Qe2\", \"response_id\": \"rsp_51Tz\"}}",
 "carry": "",
 "facts": "The page's checks (0 fail, 2 warn, 19 pass, 0 n/a):\n- Events | names | pass | all 5 events follow it\n- Events | delimiter | pass | \".\" throughout\n- Events | described | warn | 2 of 5 have no description\n- Envelope | type | pass | \"form.submitted\"\n- Envelope | id | pass | msg_2KWPBgLlAfxdpx2AI54pPJ85f4W (header)\n- Envelope | timestamp | warn | 1706100000123 - milliseconds; the header carries Unix seconds\n- Envelope | size | pass | 123 bytes\n- Signing | scheme | pass | all three\n- Signing | replay | pass | signed with the timestamp\n- Signing | format | pass | v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=\n- Signing | rotation | pass | multiple active secrets (as stated)\n- Delivery | retries | pass | 7 retries over 27.6 h, exponential\n- Delivery | span | pass | 27.6 h\n- Delivery | 4xx | pass | not retried (as stated)\n- Delivery | timeout | pass | 15 s\n- Delivery | ordering | pass | best-effort, stated\n- Delivery | dlq | pass | 14 days\n- Delivery | disable | pass | yes\n- Subscriptions and debugging | logs | pass | yes\n- Subscriptions and debugging | replay | pass | yes\n- Subscriptions and debugging | tests | pass | yes\nEvent name lint: form.submitted (ok), form.updated (ok), response.deleted (ok), file.uploaded (ok), workspace.member_added (ok).\nSigning scheme read from the delivery: Standard Webhooks.\nmust: every listed event appears once, as a name or in replaces; every name is\n\n[... 217 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "catalog",
 "summary": "All five names already follow resource.action in the past tense, but two events ship without a description and the webhook-timestamp header carries milliseconds; describe every event and send Unix seconds first.",
 "convention": {
  "pattern": "Every event is resource.action in lowercase, dot-delimited, with snake_case inside a segment and a past-tense action, such as workspace.member_added.",
  "bubbling": "Events do not bubble: a deleted response fires response.deleted only, not form.updated, and every response or file event carries its form_id so an automation can filter by form."
 },
 "events": [
  {
   "name": "form.submitted",
   "replaces": "",
   "description": "A respondent submitted a completed response to a form.",
   "payload": "full",
   "fields": [
    "form_id",
    "response_id",
    "submitted_at",
    "answers keyed by field id and label",
    "file_ids"
   ]
  },
  {
   "name": "form.updated",
   "replaces": "",
   "description": "A form's fields were added, removed or edited.",
   "payload": "full",
   "fields": [
    "form_id",
    "title",
    "fields with id, label and type",
    "updated_at"
   ]
  },
  {
   "name": "response.deleted",
   "replaces": "",
   "description": "A stored form response was deleted by the team.",
   "payload": "thin",
   "fields": [
    "response_id",
    "form_id",
    "deleted_at"
   ]
  },
  {
   "name": "file.uploaded",
   "replaces": "",
   "description": "A respondent uploaded a file to a form's file field.",
   "payload": "thin",
   "fields": [
    "file_id",
    "form_id",
    "response_id",
    "field_id",
    "file name",
    "content type",
    "size_bytes"
   ]
  },
  {
   "name": "workspace.member_added",
   "replaces": "",
   "description": "A new member was added to a workspace.",
   "payload": "thin",
   "fields": [
    "workspace_id",
    "member_id",
    "role",
    "added_at"
   ]
  }
 ],
 "envelope": {
  "placement": "headers",
  "payload": "full",
  "versioning": "event-versioned",
  "why": "No-code automations map fields straight from the request and rarely make an authenticated follow-up call, so form.submitted and form.updated carry the full answers under about 20 kB while files and deletions stay thin, and metadata sits in [... 171 more characters cut from this display]"
 },
 "example": {
  "headers": {
   "webhook-id": "msg_2KWPBgLlAfxdpx2AI54pPJ85f4W",
   "webhook-timestamp": "1706100000",
   "webhook-signature": "v1,EXAMPLEonlyNotARealSignatureAAAAAAAAAAAAAAA=",
   "content-type": "application/json"
  },
  "body": {
   "type": "form.submitted",
   "timestamp": "2024-01-24T12:40:00Z",
   "data": {
    "form_id": "frm_9Qe2",
    "response_id": "rsp_51Tz",
    "submitted_at": "2024-01-24T12:39:57Z",
    "answers": [
     {
      "field_id": "fld_name",
      "label": "Name",
      "value": "Example Respondent"
     },
     {
      "field_id": "fld_size",
      "label": "Team size",
      "value": "4"
     }
    ],
    "file_ids": []
   }
  }
 },
 "caveats": [
  "Full payloads send respondents' answers to each subscriber's endpoint; uploaded files themselves never ride in a delivery and are fetched by file_id.",
  "Fix webhook-timestamp to Unix seconds before launch - receivers checking the 5-minute window will reject a millisecond value."
 ]
}

Cut from this display only (the saved reply and the check use the full text): envelope.why (410 characters, 239 shown).

What step 5 prints for it: form.submitted | form.updated | response.deleted | file.uploaded | workspace.member_added

The page's check: kept - nothing to flag

Build the input yourself

Load the engine in node (global.window = global; require("./hookkit.js"); require("./hookreply.js")), then:

const K = HookKit, R = HookReply;
const f = K.form(Object.assign(K.blank(), { name: "Acme", events: "order.create | an order was placed\norder.shipped | it shipped",
  delivery: "X-Acme-Signature: sha256=abc\n\n{\"type\": \"order.create\"}", schedule: "1m, 1m, 1m" }));
// f.rows (every check: area, id, label, status, evidence), f.lint, f.sig, f.schedF, f.build, f.retry
// K.snippets(f) - Node, Python and Go verification; K.verify(f) - a Promise, WebCrypto, with f.secret
const b = K.buildInput(f, "catalog");                        // or "contract" (R.laneForm(form, "contract", catalog) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "catalog");
// R.check(reply, b)                                         // {status: kept or flagged, notes}

Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://hook-contract.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"hook-contract"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "catalog", "platform": "Platform: Billquent\nConsumers: Typical API developers\n...", "delivery": "POST /webhooks/billquent\nContent-Type: application/json\n...", "carry": "", "facts": "The page's checks (10 fail, 3 warn, ...):\n- Events | names | fail | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses hook-contract:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A catalog reply carries summary, convention, events, envelope, example and caveats; a contract reply carries summary, build, guarantee, ordering, dedup, retry, timeout_s, non_retriable, retry_after, dlq, disable, lifecycle, portal, docs and caveats. The code below prints the gist of either: each event name, or the guarantee and the retry schedule.

KEY="hook-contract:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "contract" then "\(.guarantee): \(.retry.schedule | map(tostring) | join(", "))" 
       else (.events[] | .name) end' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: hook-contract:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json