# API change webhooks



An API on [SKMTC Hub](https://skmtc.dev) 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`:

```text
https://docs.skmtc.dev/openapi/api-change-webhooks-v1.json
```

[Download the OpenAPI document](https://docs.skmtc.dev/openapi/api-change-webhooks-v1.json).
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 [#quick-start]

1. Sign in to Hub and open the page of the API.
2. Select **Add webhook**, enter the **Endpoint URL** of your receiver, and
   select **Add webhook**.
3. Copy the secret. Hub shows it one time only. Keep it where your receiver
   can read it.
4. 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 [#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 [#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.

```http
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 [#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](#verify-the-signature). |

### Event types [#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 [#cloudevent-attributes]

* `id` is the same on every try and every redelivery of an event. Use it to
  find duplicates.
* `source` is the Hub site that sent the event.
* `subject` is the API, as `{owner}/{slug}`.
* `time` gives the time when Hub recorded the event. It is not the
  publisher's time.

### Revision event data [#revision-event-data]

* `api`: the `id`, `owner`, `slug`, `name` and `url` of 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`, `warning` and
  `info`.
* `changesUrl`: the Hub page that lists the changes of `revision`.

Each revision has these fields:

* `id`: the Hub id of the revision.
* `contentHash`: the SHA-256 of the document. It is `null` for a revision
  with no stored document.
* `version`: the publisher's version, or `null`. It does not give an
  order.
* `publishedAt`: the publisher's time, or `null` when 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 [#test-event]

A test event has the same headers and attributes. Its `data` names the API
and contains no revision:

```json
{
  "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 [#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 the `t` value from the header.
* `<body>` is the raw request body.

Your receiver must:

1. 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.
2. Compare the result with `v1` in constant time.
3. Refuse a request whose `t` is more than five minutes from your clock.
4. Use the CloudEvent `id` to 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.

```ts
// 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:

```ts
// 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))
```

```sh
SKMTC_WEBHOOK_SECRET=whsec_… deno run --allow-net --allow-env main.ts
```

Hub 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 [#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.

```text
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=e4db6c8606b90774937b3e156acce017b8cbf64067d9e3f6ac592f9ff3bb890a
```

The request in [The request](#the-request) has a signature from the same
secret.

## Respond [#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 [#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-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 [#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 [#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 [#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.
