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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The 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. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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:
catalog- the event catalog: the naming convention and the bubbling rule, every listed event renamed where needed and described with its payload, the envelope (metadata placement, thin or full payloads, the versioning camp) and one example delivery.contract- the delivery contract: the build rung, at-least-once, ordering, dedup, the exact retry schedule, the ack timeout, dead-lettering, auto-disable, the subscription states, the debugging surface and the subscriber-facing docs.
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
| Field | Required | What |
|---|---|---|
task | yes | catalog or contract. If it is missing or neither, do catalog and set "task": "catalog". |
platform | yes | the 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. |
delivery | yes | one real delivery as pasted: headers, a blank line, the raw body; "(not pasted)" means not pasted. |
carry | send it (empty for catalog) | contract task only: the catalog's event names, when the catalog task ran first; may be empty. |
facts | yes | the 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_note | no | optional; 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:
- Events are named resource.action with the action in the past tense (invoice.paid, never invoice.pay), dot-delimited, [a-zA-Z0-9_.] only, one convention throughout. The catalog is the contract: every event type has a stable name, a description and a payload schema before it ships. State whether subresource events bubble to their parent.
- The envelope: choose deliberately where metadata lives (headers, Standard Webhooks style, or the body), thin payloads (identifiers, fetch the rest) or full ones (under about 20 kB), and one schema-versioning camp - endpoints pinned to an API version, or event types versioned by name with additive-only evolution. An API version bump never silently reshapes a payload a subscriber already stores.
- Signing follows Standard Webhooks: webhook-id, webhook-timestamp and webhook-signature on every delivery; HMAC-SHA256 over id.timestamp.raw_body, base64, prefixed v1,; receivers reject timestamps outside a 5-minute window. Secrets are prefixed whsec_ (whsk_ and whpk_ for ed25519, signature id v1a). The signature header carries several space-delimited signatures so rotation never breaks verification. Docs cover the three pitfalls: verify every request, verify the raw body, exempt the route from session-auth middleware.
- Delivery is at-least-once, said explicitly, with a stable per-event ID for dedup; never exactly-once. Ordering is best-effort by default and FIFO only as an opt-in. Delivery is queued and isolated per endpoint.
- Retries: a fixed exponential schedule spanning roughly a day by default (8 attempts over about 27.5 hours is the reference shape; 4 hours is the short end, 3 days the long end), configurable per-endpoint schedules with a circuit breaker for very many endpoints or enterprise SLAs; never linear, never zero automatic retries without a redeliver API. 2xx acks within about 10 seconds; honour Retry-After on 429; non-retriable 4xx (anything but 408 and 429) go straight to the dead-letter queue. Dead-letter on two triggers - budget exhausted, or a non-retriable error - and keep 7 to 30 days with headers. After sustained failure, disable the endpoint and notify out-of-band.
- Subscriptions are stateful objects with full CRUD and three states: active, paused (by the subscriber), disabled (by the platform). Say where a new subscription starts and where one resumes after an incident; say loudly that deleting a subscription destroys its replay state.
- The debugging surface is table stakes: delivery logs with per-attempt headers and body, retry (a new attempt of the same event) and replay (re-ingest as a new event) as distinct operations, and a send-test-event action per event type.
- Documentation passes two gates: the contract (guarantee, ordering, retry schedule, ack timeout, the dedup ID's location) and the five-element rubric (verification snippets, the catalog with example payloads, the exact retry schedule, troubleshooting, testing tools).
House rules
- Use only the input. Never invent a customer, an endpoint URL, a traffic figure, a vendor price or a claim about the platform. The skill's reference numbers (5 minutes, about 20 kB, about 10 seconds, 7 to 30 days, 4 hours, about 27.5 hours, 3 days) may be stated.
- The page's checks are settled: never call a failed check fine or a passed one broken.
- No URL at all - examples use paths and identifiers, never a domain.
- Write in the language of the input; keep the JSON keys, event names and header names in English.
- Never use backticks, never write the input's field names as labels in your text, and never copy this prompt's wording into a reply.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
catalog
Write the event catalog and the envelope.
summary- one sentence, at most 40 words: what is wrong with the events today and the first fix.convention-pattern(one sentence: the naming rule),bubbling(one sentence: whether subresource events fire on their parent).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(thinorfull- 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).envelope-placement(headersorbody),payload(thinorfull),versioning(endpoint-pinnedorevent-versioned),why(one or two sentences).example- one complete delivery for one of your events:headers(webhook-id, webhook-timestamp as Unix seconds, webhook-signature as v1,<base64>, content-type) andbody(the JSON body, with type equal to that event's name).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.
summary- one sentence, at most 40 words.build-choice(managed,self-hostedorin-house, as the facts settle it),why(one sentence).guarantee- exactly "at-least-once".ordering-best-effortorfifo-opt-in.dedup- one sentence: which header or field carries the stable event ID and what the subscriber does with it.retry-schedule(the seconds to wait before each attempt, the first being 0),span_hours(their sum, in hours),why(one sentence).timeout_s- the ack timeout in seconds.non_retriable- the statuses sent straight to the dead-letter queue, as numbers.retry_after- true when a 429's Retry-After is honoured over the schedule.dlq-retention_days,triggers(the two triggers, as short strings).disable-after(when an endpoint is disabled),notify(how the subscriber is told, out-of-band).lifecycle-states(["active", "paused", "disabled"]),start(where a new subscription starts),resume(where one resumes after an incident),delete_warning(one sentence).portal-logs,replay,retryandtest_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.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.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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://hook-contract.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// hook-contract.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://hook-contract.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://hook-contract.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// Hook Contract.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class Hook Contract {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://hook-contract.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://hook-contract.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://hook-contract.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://hook-contract.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "hook-contract"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "hook-contract" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "hook-contract"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "hook-contract"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "hook-contract" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "hook-contract"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "hook-contract" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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}'
inp = json.load(open("input.json")) # {"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
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "catalog" or "contract", platform, delivery, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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
import hashlib, time
key = "hook-contract:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "contract":
print(reply["guarantee"], reply["retry"]["schedule"])
else:
for e in reply["events"]:
print(e["name"])
import { createHash } from "node:crypto";
const key = `hook-contract:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "contract") console.log(reply.guarantee, reply.retry.schedule.join(", "));
else for (const e of reply.events) console.log(e.name);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "hook-contract:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
if reply["task"] == "contract" {
rt, _ := reply["retry"].(map[string]any)
fmt.Println(reply["guarantee"], rt["schedule"])
} else {
es, _ := reply["events"].([]any)
for _, e := range es {
fmt.Println(e.(map[string]any)["name"])
}
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "hook-contract:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("contract")) {
System.out.println(reply.path("guarantee").asText() + " " + reply.path("retry").path("schedule"));
} else {
for (JsonNode e : reply.path("events")) System.out.println(e.path("name").asText());
}
require "digest"
key = "hook-contract:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "contract"
puts "#{reply["guarantee"]} #{reply.dig("retry", "schedule").join(", ")}"
else
reply["events"].each { |e| puts e["name"] }
end
$key = "hook-contract:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "contract") { echo $reply["guarantee"], " ", implode(", ", $reply["retry"]["schedule"]), "\n"; }
else { foreach ($reply["events"] as $e) echo $e["name"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"hook-contract:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "contract") Console.WriteLine($"{reply["guarantee"]} {reply["retry"]?["schedule"]?.ToJsonString()}");
else { foreach (var e in reply["events"]!.AsArray()) Console.WriteLine($"{e!["name"]}"); }
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "hook-contract:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `hook-contract:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "hook-contract:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "hook-contract:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "hook-contract:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: hook-contract:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"hook-contract:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);