Skip to content

Webhooks

Register webhook endpoints, receive signed events when revisions publish, scans finish, migrations complete, or delivery crosses a threshold, and verify each request.

On this page

Webhooks let your systems react when something changes in a SteadyLink workspace: purge a cache when a new revision goes live, alert a channel when a scan finds malware, or close a ticket when a migration finishes. This page covers registering endpoints, every event and its payload, how to verify the signature, and how retries work.

A workspace can send events to two kinds of destination:

  • A generic HTTPS endpoint that you run. It receives the JSON event with an HMAC signature you can verify.
  • A Discord, Slack, or Microsoft Teams channel. SteadyLink formats each event as a native message for that app. These requests are not signed, because the channel URL itself is the credential.

Register an endpoint#

In the dashboard, open Dashboard > Developers, choose the Webhooks tab, and select New webhook. Pick the destination type, paste the URL, and tick the events. For a generic endpoint, the signing secret is shown once, right after you create it.

Through the API, managing webhooks needs a workspace admin. With an API key, listing needs the workspace:read scope, and creating, testing, and deleting need workspace:admin.

Create a webhook#

POST/api/platform/webhooks
Requiresworkspace:admin

Body

urlstringRequired
The destination, 12 to 2,048 characters. Must be a public https:// URL with no username or password in it. localhost, .local hosts, and private or reserved IP addresses are rejected, and the host is resolved again before every delivery so it cannot later point at a private network.
eventsstring[]Required
1 to 10 event types from Events. Duplicates are removed.
destinationTypestringDefault generic
generic, discord, slack, or teams. See Destination types for the URLs each one accepts.
curl -X POST https://api.steadylink.io/api/platform/webhooks \
  -H "X-API-Key: $STEADYLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/hooks/steadylink",
    "destinationType": "generic",
    "events": ["asset.revision.published", "asset.scan.completed"]
  }'
201 CreatedResponse
{
  "id": "0f6b2c8e-3d1a-4b7e-9c5f-2a8d4e6b1c93",
  "url": "https://app.example.com/hooks/steadylink",
  "destinationType": "generic",
  "events": ["asset.revision.published", "asset.scan.completed"],
  "enabled": true,
  "createdAt": "2026-10-08T09:41:27.115032",
  "secret": "whsec_kP3x9QmZ2vR7tLw1bN8cY4sD6fH0jG5aE2uI9oKqT3M"
}

secret is returned only here, and only for generic endpoints. SteadyLink stores it encrypted and cannot show it again; if you lose it, delete the webhook and create a new one.

StatusCodeWhen
409feature_limit_reachedThe workspace already has as many endpoints as its plan allows.
422invalid_webhook_urlThe URL is not public HTTPS, or does not match the destination type.
422unsupported_webhook_eventsAn event type is not in the list below. The response names the unsupported events.

List webhooks#

GET/api/platform/webhooks
Requiresworkspace:read

Returns every endpoint and the event types you can subscribe to:

200 OKResponse
{
  "availableEvents": ["asset.delivery.threshold", "asset.revision.published", "asset.scan.completed", "migration.completed"],
  "items": [
    {
      "id": "0f6b2c8e-3d1a-4b7e-9c5f-2a8d4e6b1c93",
      "url": "https://app.example.com/hooks/steadylink",
      "destinationType": "generic",
      "events": ["asset.revision.published", "asset.scan.completed"],
      "enabled": true,
      "createdAt": "2026-10-08T09:41:27.115032"
    },
    {
      "id": "7a2e4c9b-1f3d-4e8a-b6c0-5d9f2a7e3b14",
      "url": "slack://hooks.slack.com/••••••",
      "destinationType": "slack",
      "events": ["asset.scan.completed"],
      "enabled": true,
      "createdAt": "2026-10-08T09:44:02.903871"
    }
  ]
}

Channel URLs contain a credential, so they are stored encrypted and listed only as {type}://{host}/••••••.

Send a test event#

POST/api/platform/webhooks/{webhook_id}/test
Requiresworkspace:admin

Sends one event to the endpoint immediately and waits for the answer, so you can check your receiver end to end. The test event uses the endpoint's first subscribed event type (alphabetically) and this data:

JSON
{ "test": true, "message": "Your SteadyLink webhook is connected." }

A generic test is signed like any other delivery. The response is { "delivered": true, "responseStatus": 200, "deliveryId": "..." } when your endpoint returns a 2xx. Anything else, including a timeout, returns 502 with the code webhook_delivery_failed and the reason in message. Tests count toward the monthly delivery allowance and are not retried.

List recent deliveries#

GET/api/platform/webhooks/{webhook_id}/deliveries
Requiresworkspace:read

Returns the 50 most recent deliveries for the endpoint, newest first:

200 OKResponse
{
  "items": [
    {
      "id": "b3d9f1a2-6c4e-4f8b-9a1d-7e2c5b8f0a36",
      "event": "asset.revision.published",
      "status": "delivered",
      "attempts": 2,
      "responseStatus": 200,
      "error": null,
      "createdAt": "2026-10-08T10:02:15.448190"
    }
  ]
}

status is queued, retrying, delivered, failed (retries exhausted), or cancelled (the endpoint was disabled before delivery). A test in progress shows processing. error holds the last failure, such as Endpoint returned HTTP 500.

Delete a webhook#

DELETE/api/platform/webhooks/{webhook_id}
Requiresworkspace:admin

Returns 204 No Content. There is no update route: to change the URL or events, create the new webhook first, then delete the old one, so no events are missed in between.

Destination types#

destinationTypeAccepted URLsWhat is sent
genericAny public HTTPS URLThe JSON event, signed.
discordhttps://discord.com/... or https://discordapp.com/... with /webhooks/ in the pathA Discord embed with a title, description, up to four fields, and a link to the file.
slackhttps://hooks.slack.com/services/... or https://hooks.slack-gov.com/services/...Slack Block Kit: header, description, fields, and an Open file button.
teamsA Teams Workflow URL on *.webhook.office.com, *.logic.azure.com, *.environment.api.powerplatform.com, or *.powerplatform.comAn Adaptive Card with the same content.

Channel messages are written for people (for example "New revision is live: hero.webp is now serving revision v4.") and do not include the full event. Use a generic endpoint when a program needs the data.

Events#

EventFires when
asset.revision.publishedA new revision becomes current: a file is uploaded (new or to an existing key, through the API, dashboard, S3, or a migration), a file is replaced, or an older revision is promoted or rolled back.
asset.scan.completedThe malware scan of a revision finishes (clean, infected, or error), or a synchronous scan blocks an upload.
asset.delivery.thresholdDelivery bandwidth for the billing period crosses 50, 80, or 100 percent of the plan allowance. Each threshold fires once per period.
migration.completedAll files in a bulk migration reach a final state.

Payload#

Every event delivered to a generic endpoint has the same envelope:

Envelope

idstring
Unique event ID. Retries of the same event reuse it. Treat it as opaque: it may start with evt_ or encode its source, as in scan:{revisionId}:clean.
typestring
The event type.
createdAtstring
When the event was queued, ISO 8601 in UTC, for example 2026-10-08T10:02:15.448190Z.
dataobject
Event-specific fields, described below.

The body is compact JSON with keys sorted alphabetically.

asset.revision.published#

data describes the action that published the revision:

data

actionstring
asset.object_version_create (an upload), bucket.object_replace (a replacement), asset.commit (a direct commit), or asset.revision_promote (a promotion or rollback).
assetIdstring
The file's asset ID, except for bucket.object_replace, where it is the bucket ID and the file's asset ID is in metadata.objectAssetId.
successboolean
Always true for published revisions.
metadataobject
Depends on action. Always includes version, the revision now current. Uploads add bucketId, key, and scan_status; replacements add key and objectAssetId; direct commits add mime and byte_size; promotions add previousRevisionId.
asset.revision.published
{
  "createdAt": "2026-10-08T10:02:15.448190Z",
  "data": {
    "action": "bucket.object_replace",
    "assetId": "8b1f6c2e-4a7d-4f0e-b3c9-2d5e7a9f1c04",
    "metadata": {
      "key": "campaign/hero.webp",
      "objectAssetId": "3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d",
      "version": 4
    },
    "success": true
  },
  "id": "audit:7d3c1f2e-9a4b-4c6d-8e1f-0a2b3c4d5e6f:bucket.object_replace:8b1f6c2e-4a7d-4f0e-b3c9-2d5e7a9f1c04",
  "type": "asset.revision.published"
}

To get the file's asset ID reliably, use data.metadata.objectAssetId when it is present and data.assetId otherwise.

asset.scan.completed#

When a background scan finishes:

asset.scan.completed
{
  "createdAt": "2026-10-08T10:02:31.902114Z",
  "data": {
    "assetId": "3f2a9c1e-7b4d-4e1a-9c2f-5d8e6a1b0c3d",
    "revision": 4,
    "revisionId": "e4a1c7b2-9d3f-4a6e-8b0c-1f5d2e9a7c48",
    "status": "clean"
  },
  "id": "scan:e4a1c7b2-9d3f-4a6e-8b0c-1f5d2e9a7c48:clean",
  "type": "asset.scan.completed"
}

status is clean, infected, or error. An infected revision is never served. An error means the scan could not complete; the dashboard shows the revision's state.

When a synchronous scan blocks a file before it is stored, data instead has the audit shape: action is asset.scan_infected, assetId is the bucket ID, success is true, and metadata holds the scanner's signature in sig plus the temporary key and, for uploads, the path and filename. No revision is created in that case.

asset.delivery.threshold#

asset.delivery.threshold
{
  "createdAt": "2026-10-08T11:15:00.031877Z",
  "data": {
    "limit": 53687091200,
    "periodEnd": "2026-11-01T00:00:00",
    "periodStart": "2026-10-01T00:00:00",
    "resource": "delivery_bytes",
    "threshold": 80,
    "usage": 42950000000
  },
  "id": "usage:...:delivery_bytes:2026-10-01T00:00:00:80",
  "type": "asset.delivery.threshold"
}

usage and limit are bytes. threshold is 50, 80, or 100. If one burst of traffic crosses several thresholds at once, only the highest one is sent.

migration.completed#

migration.completed
{
  "createdAt": "2026-10-08T12:40:09.554201Z",
  "data": {
    "completedFiles": 98,
    "failedFiles": 2,
    "migrationId": "1c7e3a9d-5b2f-4e8c-a6d0-9f3b2e1a7c54",
    "status": "partial",
    "uploadBatchId": "6d2b8f4a-0e3c-4a9d-b7f1-3c5e8a2d9b06"
  },
  "id": "migration:1c7e3a9d-5b2f-4e8c-a6d0-9f3b2e1a7c54:partial",
  "type": "migration.completed"
}

status is complete when every file succeeded and partial when some failed.

Verify the signature#

Requests to a generic endpoint carry these headers:

HeaderValue
Content-Typeapplication/json
User-AgentSteadyLink-Webhooks/1.0
X-SteadyLink-IdThe event ID, the same as id in the body.
X-SteadyLink-TimestampUnix time in seconds when this attempt was sent.
X-SteadyLink-Signaturev1= followed by the lowercase hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with your webhook secret.

To verify a request:

  1. Read the body as raw bytes, before any JSON parsing. Re-serializing parsed JSON changes the bytes and breaks the signature.
  2. Reject the request if the timestamp is more than five minutes from your current time. This stops an intercepted request from being replayed later.
  3. Compute HMAC-SHA256 over the timestamp, a period, and the raw body, using the full secret string (including its whsec_ prefix) as the key.
  4. Prefix the hex digest with v1= and compare it to X-SteadyLink-Signature with a constant-time comparison.

Each retry is signed again with a new timestamp, so a retried delivery never fails the age check.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const secret = process.env.STEADYLINK_WEBHOOK_SECRET!; // whsec_...

export function verifySteadyLink(rawBody: Buffer, timestamp: string | undefined, signature: string | undefined): boolean {
  if (!timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const digest = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
  const expected = Buffer.from(`v1=${digest}`);
  const received = Buffer.from(signature);
  return expected.length === received.length && timingSafeEqual(expected, received);
}

const app = express();

app.post("/hooks/steadylink", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySteadyLink(req.body, req.get("X-SteadyLink-Timestamp"), req.get("X-SteadyLink-Signature"))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // Store the event (deduplicated by event.id) and process it asynchronously.
  res.sendStatus(204);
});

In a Next.js route handler, read the body with await request.arrayBuffer() and pass Buffer.from(...) to verifySteadyLink.

Respond, retries, and deduplication#

Return any 2xx status as soon as you have stored the event. SteadyLink waits up to 10 seconds for a response; do slow work after responding. Answer at the registered URL itself rather than redirecting, because a redirected POST is not re-sent with its body.

A delivery that times out, cannot connect, or gets any non-2xx status is retried with exponential backoff: roughly 2, 4, 8, and 16 seconds after the first, second, third, and fourth failures. After five failed attempts the delivery is marked failed and no more attempts are made. You can see every attempt in delivery history.

Design your receiver for at-least-once delivery:

  • Deduplicate by event ID. A retry reuses the same id (also in X-SteadyLink-Id). If your endpoint processed an event but the response was lost, the retry arrives with the same ID; skip IDs you have already handled. SteadyLink itself never queues the same event ID twice for one endpoint.
  • Do not rely on order. Retries and parallel work mean asset.scan.completed can arrive before the asset.revision.published for the same revision. Use createdAt and the revision number to order events.
  • Re-read when it matters. Events carry identifiers, not full state. Fetch the file or revision from the API before acting on something like visibility or the current revision.

Limits#

Endpoints and deliveries are counted per workspace:

PlanWebhook endpointsDeliveries per month
Free1100
Personal31,000
Pro1510,000
Business100100,000
Enterprise1,0001,000,000

Each event sent to each endpoint is one delivery, including test events; retries of a delivery do not count again. When the monthly allowance is used up, new events are not queued for the rest of the billing period, and they are not sent later. Test sends return 409 feature_limit_reached instead.

Webhooks and notification settings#

Workspace webhooks on this page are for systems: they deliver the four event types above to the endpoints an admin registers, with a signature for generic receivers.

Notification settings are for people. Each member chooses which notifications they get, such as file replacements, request submissions, and failed jobs, and whether each one arrives in the app, by email, or through the Webhook channel. See Notifications. Notification payloads contain a dashboard link and resource identifiers, never file contents or signed download URLs.

Next steps#