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
- Register an endpoint
- Create a webhook
- List webhooks
- Send a test event
- List recent deliveries
- Delete a webhook
- Destination types
- Events
- Payload
- asset.revision.published
- asset.scan.completed
- asset.delivery.threshold
- migration.completed
- Verify the signature
- Respond, retries, and deduplication
- Limits
- Webhooks and notification settings
- Next steps
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#
/api/platform/webhooksworkspace:adminBody
urlstringRequired- The destination, 12 to 2,048 characters. Must be a public
https://URL with no username or password in it.localhost,.localhosts, 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.
destinationTypestringDefaultgenericgeneric,discord,slack, orteams. 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"]
}'// createWebhook() always registers a generic endpoint.
const webhook = await steadylink.createWebhook("https://app.example.com/hooks/steadylink", [
"asset.revision.published",
"asset.scan.completed",
]);
console.log(webhook.secret); // store it nowwebhook = client.create_webhook(
"https://app.example.com/hooks/steadylink",
["asset.revision.published", "asset.scan.completed"],
)
print(webhook["secret"]) # store it now{
"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.
| Status | Code | When |
|---|---|---|
409 | feature_limit_reached | The workspace already has as many endpoints as its plan allows. |
422 | invalid_webhook_url | The URL is not public HTTPS, or does not match the destination type. |
422 | unsupported_webhook_events | An event type is not in the list below. The response names the unsupported events. |
List webhooks#
/api/platform/webhooksworkspace:readReturns every endpoint and the event types you can subscribe to:
{
"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#
/api/platform/webhooks/{webhook_id}/testworkspace:adminSends 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:
{ "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#
/api/platform/webhooks/{webhook_id}/deliveriesworkspace:readReturns the 50 most recent deliveries for the endpoint, newest first:
{
"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#
/api/platform/webhooks/{webhook_id}workspace:adminReturns 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#
destinationType | Accepted URLs | What is sent |
|---|---|---|
generic | Any public HTTPS URL | The JSON event, signed. |
discord | https://discord.com/... or https://discordapp.com/... with /webhooks/ in the path | A Discord embed with a title, description, up to four fields, and a link to the file. |
slack | https://hooks.slack.com/services/... or https://hooks.slack-gov.com/services/... | Slack Block Kit: header, description, fields, and an Open file button. |
teams | A Teams Workflow URL on *.webhook.office.com, *.logic.azure.com, *.environment.api.powerplatform.com, or *.powerplatform.com | An 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#
| Event | Fires when |
|---|---|
asset.revision.published | A 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.completed | The malware scan of a revision finishes (clean, infected, or error), or a synchronous scan blocks an upload. |
asset.delivery.threshold | Delivery bandwidth for the billing period crosses 50, 80, or 100 percent of the plan allowance. Each threshold fires once per period. |
migration.completed | All 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 inscan:{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
actionstringasset.object_version_create(an upload),bucket.object_replace(a replacement),asset.commit(a direct commit), orasset.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 inmetadata.objectAssetId. successboolean- Always
truefor published revisions. metadataobject- Depends on
action. Always includesversion, the revision now current. Uploads addbucketId,key, andscan_status; replacements addkeyandobjectAssetId; direct commits addmimeandbyte_size; promotions addpreviousRevisionId.
{
"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:
{
"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#
{
"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#
{
"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:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SteadyLink-Webhooks/1.0 |
X-SteadyLink-Id | The event ID, the same as id in the body. |
X-SteadyLink-Timestamp | Unix time in seconds when this attempt was sent. |
X-SteadyLink-Signature | v1= followed by the lowercase hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with your webhook secret. |
To verify a request:
- Read the body as raw bytes, before any JSON parsing. Re-serializing parsed JSON changes the bytes and breaks the signature.
- Reject the request if the timestamp is more than five minutes from your current time. This stops an intercepted request from being replayed later.
- Compute HMAC-SHA256 over the timestamp, a period, and the raw body, using the full secret string (including its
whsec_prefix) as the key. - Prefix the hex digest with
v1=and compare it toX-SteadyLink-Signaturewith 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);
});import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["STEADYLINK_WEBHOOK_SECRET"] # whsec_...
app = Flask(__name__)
def verify(secret: str, timestamp: str | None, body: bytes, signature: str | None) -> bool:
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
digest = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"v1={digest}", signature)
@app.post("/hooks/steadylink")
def steadylink_webhook():
body = request.get_data() # raw bytes
if not verify(SECRET, request.headers.get("X-SteadyLink-Timestamp"), body, request.headers.get("X-SteadyLink-Signature")):
abort(401)
event = json.loads(body)
# Store the event (deduplicated by event["id"]) and process it asynchronously.
return "", 204In 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 inX-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.completedcan arrive before theasset.revision.publishedfor the same revision. UsecreatedAtand 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:
| Plan | Webhook endpoints | Deliveries per month |
|---|---|---|
| Free | 1 | 100 |
| Personal | 3 | 1,000 |
| Pro | 15 | 10,000 |
| Business | 100 | 100,000 |
| Enterprise | 1,000 | 1,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.