API change webhooks
Get a signed CloudEvent at your HTTPS endpoint when an API on SKMTC Hub changes, and verify that Hub sent it.
An API on SKMTC Hub can get a new latest revision that differs from the revision before it. Then Hub sends a signed CloudEvent to each webhook on that API. This page tells you how to add a webhook, receive the request, and check its signature.
An OpenAPI 3.1 document describes the request. It has one entry under
webhooks and no paths:
https://docs.skmtc.dev/openapi/api-change-webhooks-v1.jsonDownload the OpenAPI document. Load it by URL in Swagger Editor, Scalar or a code generator. It gives the request headers, the event schemas, and what Hub does with each response.
This page is about the webhooks Hub sends to you. It is not about the
webhooks section of an OpenAPI document that you publish.
Quick start
- Sign in to Hub and open the page of the API.
- Select Add webhook, enter the Endpoint URL of your receiver, and select Add webhook.
- Copy the secret. Hub shows it one time only. Keep it where your receiver can read it.
- Select Send test event. Hub sends a test event at once and shows the result in the dialog. The test is optional.
You can add a webhook to any API you can view. You can add more than one endpoint to an API, but each endpoint URL only one time.
To manage your webhooks later, open the Subscriptions tab of your account
(https://skmtc.dev/{your-handle}/subscriptions). It lists your API webhooks
across all APIs.
Endpoint requirements
- The URL uses
https. - The host is public. Hub refuses loopback, private and reserved addresses.
- The URL contains no user name or password.
Hub checks the URL when you add the webhook. Before each request, Hub checks the URL, each redirect, and each address that DNS gives for the host. If a check fails, Hub does not send the request, and the delivery shows Destination blocked.
The request
Hub sends one HTTP POST for each event. The body is one CloudEvent 1.0 in
structured mode, as JSON. Hub sends the body on one line; this page adds line
breaks.
POST /webhooks/skmtc HTTP/1.1
content-type: application/cloudevents+json; charset=utf-8
user-agent: SKMTC-Hub-Webhooks/1
skmtc-signature: t=1791884413,v1=54cefefcde2f4a2c76ac705d7cfc429125ca0b313e8850f03a97644c14f5762a
{
"specversion": "1.0",
"id": "whe_5d0b6f1e2a9c4d3b8e7f6a5b4c3d2e1f",
"source": "https://skmtc.dev",
"subject": "acme/petstore",
"time": "2026-10-13T09:40:12.000Z",
"datacontenttype": "application/json",
"type": "dev.skmtc.api.revision.v1",
"data": {
"api": {
"id": "api_8f2c1d4e5b6a47c9a0e3f1b2c3d4e5f6",
"owner": "acme",
"slug": "petstore",
"name": "Petstore",
"url": "https://skmtc.dev/acme/apis/petstore"
},
"revision": {
"id": "ver_0c9e8d7f6a5b4c3d2e1f0a9b8c7d6e5f",
"contentHash": "3b4f1c2d9e8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c",
"version": "v2",
"publishedAt": "2026-10-13T09:12:00.000Z",
"retrievedAt": "2026-10-13T09:40:05.000Z",
"url": "https://skmtc.dev/acme/apis/petstore/revisions/3b4f1c2d9e8a"
},
"previousRevision": {
"id": "ver_7a6b5c4d3e2f41a0b9c8d7e6f5a4b3c2",
"contentHash": "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d",
"version": "v2",
"publishedAt": null,
"retrievedAt": "2026-10-06T14:02:51.000Z",
"url": "https://skmtc.dev/acme/apis/petstore/revisions/9e8d7c6b5a4f"
},
"summary": {
"diff": true,
"details": {
"endpoints": { "added": 1, "deleted": 0, "modified": 2 },
"schemas": { "added": 0, "deleted": 1, "modified": 1 }
}
},
"counts": { "breaking": 1, "warning": 0, "info": 3 },
"changesUrl": "https://skmtc.dev/acme/apis/petstore/revisions/3b4f1c2d9e8a/changelog"
}
}Headers
| Header | Value |
|---|---|
content-type | application/cloudevents+json; charset=utf-8 |
user-agent | SKMTC-Hub-Webhooks/1 |
skmtc-signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. See Verify the signature. |
Event types
type | Sent when |
|---|---|
dev.skmtc.api.revision.v1 | A push or a refetch makes a new revision the latest revision of the API, and the diff with the revision before it finds a change. |
dev.skmtc.api.test.v1 | You select Send test event. |
Hub does not send an event when the diff finds no change, for example after a commit that changes only spaces and line breaks. A revision that Hub imports into the history of an API, behind its latest revision, does not send an event.
CloudEvent attributes
idis the same on every try and every redelivery of an event. Use it to find duplicates.sourceis the Hub site that sent the event.subjectis the API, as{owner}/{slug}.timegives the time when Hub recorded the event. It is not the publisher's time.
Revision event data
api: theid,owner,slug,nameandurlof the API.revision: the revision that became the latest revision.previousRevision: the latest revision before it. It can belong to a different version of the API.summary.details: the number of added, deleted and modified items in each section of the document.counts: the number of changes at each severity:breaking,warningandinfo.changesUrl: the Hub page that lists the changes ofrevision.
Each revision has these fields:
id: the Hub id of the revision.contentHash: the SHA-256 of the document. It isnullfor a revision with no stored document.version: the publisher's version, ornull. It does not give an order.publishedAt: the publisher's time, ornullwhen the publisher gave none.retrievedAt: when Hub retrieved the revision.url: the revision's page on Hub. For a revision with no stored document, it is the API's revisions page.
The event contains a summary and links, not the document. For a private API, the links open only for a signed-in user who can read the API.
Test event
A test event has the same headers and attributes. Its data names the API
and contains no revision:
{
"specversion": "1.0",
"id": "whe_e4d3c2b1a0f94e8d7c6b5a4f3e2d1c0b",
"source": "https://skmtc.dev",
"subject": "acme/petstore",
"time": "2026-10-13T09:45:00.000Z",
"datacontenttype": "application/json",
"type": "dev.skmtc.api.test.v1",
"data": {
"test": true,
"api": {
"id": "api_8f2c1d4e5b6a47c9a0e3f1b2c3d4e5f6",
"owner": "acme",
"slug": "petstore",
"name": "Petstore",
"url": "https://skmtc.dev/acme/apis/petstore"
},
"message": "A test event from SKMTC Hub. It describes no revision."
}
}Verify the signature
The skmtc-signature header has the form t=<unix seconds>,v1=<hex>. The
v1 value is the HMAC-SHA256 of <t>.<body>:
- The key is the whole secret of the webhook as UTF-8, with its
whsec_prefix. <t>is thetvalue from the header.<body>is the raw request body.
Your receiver must:
- Calculate the HMAC from the raw body. Do not parse the JSON first. A body that you parse and serialize again can change by one byte, and then the signature does not match.
- Compare the result with
v1in constant time. - Refuse a request whose
tis more than five minutes from your clock. - Use the CloudEvent
idto ignore an event that you already processed.
This receiver does those steps. It uses only Web Crypto and the Fetch API
Request and Response, so it runs on Deno, Bun and Cloudflare Workers. On
Node.js, compile it to JavaScript first, or use Node.js 23.6 or later, which
runs TypeScript.
// receiver.ts
const encoder = new TextEncoder()
const TOLERANCE_SECONDS = 5 * 60
/** Whether `header` is a valid skmtc-signature of `body` for `secret`. */
export const verifySignature = async (
secret: string,
header: string | null,
body: string,
now: number = Math.floor(Date.now() / 1000)
): Promise<boolean> => {
const parts = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '')
if (!parts) return false
const [, timestamp = '', hex = ''] = parts
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
)
const signature = new Uint8Array(32).map((_, index) =>
parseInt(hex.slice(index * 2, index * 2 + 2), 16)
)
// `verify` compares in constant time.
return crypto.subtle.verify('HMAC', key, signature, encoder.encode(`${timestamp}.${body}`))
}
// Event ids you already processed. Use a durable store in production.
const processed = new Set<string>()
export const handleWebhook = async (request: Request, secret: string): Promise<Response> => {
const body = await request.text()
const signature = request.headers.get('skmtc-signature')
if (!(await verifySignature(secret, signature, body))) {
return new Response('Invalid signature', { status: 401 })
}
const event = JSON.parse(body)
if (processed.has(event.id)) return new Response(null, { status: 204 })
if (event.type === 'dev.skmtc.api.revision.v1') {
const { breaking, warning, info } = event.data.counts
console.log(`${event.subject}: ${breaking} breaking, ${warning} warning, ${info} info`)
console.log(`Changes: ${event.data.changesUrl}`)
}
// After the work: if it throws, Hub gets a 5xx and the retry runs it again.
processed.add(event.id)
return new Response(null, { status: 204 })
}To run it on Deno:
// main.ts
import { handleWebhook } from './receiver.ts'
const secret = Deno.env.get('SKMTC_WEBHOOK_SECRET')
if (!secret) throw new Error('Set SKMTC_WEBHOOK_SECRET to the signing secret')
Deno.serve((request) => handleWebhook(request, secret))SKMTC_WEBHOOK_SECRET=whsec_… deno run --allow-net --allow-env main.tsHub needs a public HTTPS URL. To receive events on your computer, put a tunnel in front of the receiver.
Check your code with an example
With this secret, timestamp and body, your code must calculate the signature below. The body is the test event above, as Hub sends it: one line, with no line break at the end.
secret: whsec_l8m3Yl0iXq2t5Q7bGHzv9dN4kRcEwUjA1sPfoT6xK0M
timestamp: 1791884700
body: {"specversion":"1.0","id":"whe_e4d3c2b1a0f94e8d7c6b5a4f3e2d1c0b","source":"https://skmtc.dev","subject":"acme/petstore","time":"2026-10-13T09:45:00.000Z","datacontenttype":"application/json","type":"dev.skmtc.api.test.v1","data":{"test":true,"api":{"id":"api_8f2c1d4e5b6a47c9a0e3f1b2c3d4e5f6","owner":"acme","slug":"petstore","name":"Petstore","url":"https://skmtc.dev/acme/apis/petstore"},"message":"A test event from SKMTC Hub. It describes no revision."}}
signature: t=1791884700,v1=e4db6c8606b90774937b3e156acce017b8cbf64067d9e3f6ac592f9ff3bb890aThe request in The request has a signature from the same secret.
Respond
Return a 2xx status within 10 seconds. Hub does not read the response body. If the work is slow, put the event in a queue and respond. Hub counts a slow response as a timeout, and sends the event again.
| Your response | What Hub does |
|---|---|
| Any 2xx | The delivery succeeded. |
| 307 or 308 | Hub sends the same request to the location, for not more than three redirects. |
| 408, 429, any 5xx, a timeout or a network error | Hub tries again later. |
| Any other 3xx or 4xx | The delivery fails. Hub does not try again. |
Hub does not send the request again for a 301, 302 or 303 redirect, because
a client changes the POST to a GET for these. Give the final URL as the
endpoint URL.
Retries, duplicates and order
Hub tries a delivery again for not more than 24 hours after its first try. The first waits are about one minute, and each wait is about two times the wait before it, to not more than six hours. When the 24 hours end, the delivery fails with Retries ran out. A failed delivery does not stop later deliveries to the webhook.
Each try has a new t and a new signature, but the same event id.
Delivery is at least once: your receiver can get the same event more than
one time, for example when Hub did not get your response. Use the event
id to find duplicates.
Hub does not guarantee the order of deliveries. Use previousRevision to
find out if you missed an event.
Pause, resume and delete
- Pause. Hub keeps the deliveries that it did not send yet, and sends them after you resume. Hub sends no event for a revision that becomes the latest revision during the pause.
- Resume. Hub sends the kept deliveries within about one minute.
- Delete. Hub cancels the deliveries that it did not send yet, and removes the secret of the webhook. Hub removes the records of the webhook 30 days after the delete.
You cannot send a test event to a paused webhook.
Access to the API
Hub checks that you can still read the API before each request. If you can no longer read it, Hub cancels the deliveries that it did not send yet, shows Access lost, and pauses the webhook. Hub does not send the canceled events later, even if you get access again. To resume the webhook, you must be able to read the API.
Rotate the secret
Select Rotate secret in the actions of the webhook. Hub shows the new secret one time. Hub signs the next try with the new secret. This also applies to tries of events from before the rotation.
Until your receiver has the new secret, it refuses Hub's requests, and the deliveries fail. After you update the receiver, select Redeliver on each failed delivery. Do not pause the webhook for the rotation: Hub sends no event for a revision that becomes the latest revision during a pause.
Delivery history and troubleshooting
In the Subscriptions tab of your account, select a webhook to see its history. The history shows each try of the last 30 days: the event, the time, the response status, and the error.
| Error | Meaning |
|---|---|
| Rejected by the destination | Your endpoint returned a 3xx or 4xx that Hub does not retry. |
| Retries ran out | The endpoint did not return a 2xx within 24 hours. |
| Destination blocked | The URL, a redirect, or a DNS address is not a public HTTPS destination. |
| Access lost | You can no longer read the API. |
| Webhook deleted | The webhook was deleted before the delivery was sent. |
| Timed out | Your endpoint did not answer within 10 seconds. Hub tries again. |
| Network error | Hub could not connect to your endpoint, or the connection failed. Hub tries again. |
| DNS error | Hub could not find an address for the host. Hub tries again. |
| Event unavailable | A revision that the event names, or its change summary, is missing. Hub does not try again. |
| Secret unavailable | Hub could not read the signing secret. Hub tries again. |
To send a failed delivery again, select Redeliver on its newest try.
The webhook must be active. A redelivery has the same event id and a new
24-hour retry window.