Webhooks
Webhooks deliver real-time events from Freedam to an HTTPS endpoint you control. Use them in place of polling: when an asset is ingested, a share is opened, or an upload batch finishes, Freedam sends a signed POST with a JSON payload to your URL.
Lifecycle
- Register an endpoint with
POST /v1/webhooks. The response contains the signing secret, shown once. - Verify every incoming request with the
X-Freedam-Signature-256andX-Freedam-Timestampheaders (see below). - Acknowledge with a
2xxresponse within 30 seconds. Any other status, a timeout, or a connection error counts as a failed attempt. - Failed attempts are retried up to 5 attempts in total, waiting 10 seconds, 1 minute, 5 minutes, then 30 minutes between attempts.
- Repeated failures disable the endpoint. After 10 failed attempts in a row, the endpoint is switched off. Any successful delivery resets the counter.
Endpoints
Webhook endpoints belong to the user who owns the API token. Each token only sees and manages its own endpoints.
| Method | Path | Description |
|---|---|---|
GET |
/v1/webhooks |
List your endpoints (paginated, per_page defaults to 20). |
POST |
/v1/webhooks |
Create an endpoint. |
GET |
/v1/webhooks/{id} |
Read an endpoint, including failure_count and last_delivery_at. |
PUT |
/v1/webhooks/{id} |
Update url, description, events, or is_active. |
DELETE |
/v1/webhooks/{id} |
Permanently delete an endpoint. |
POST |
/v1/webhooks/{id}/test |
Queue a synthetic webhook.test event to this endpoint only. |
GET |
/v1/webhooks/{id}/deliveries |
List recent deliveries (paginated, newest first). |
POST |
/v1/webhooks/{id}/rotate-secret |
Generate a new signing secret. |
Creating an endpoint
curl -X POST https://your-freedam-instance.com/api/v1/webhooks \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/freedam/webhook",
"events": ["asset.created", "asset.updated", "share.created"],
"description": "Production pipeline"
}'
Request fields:
| Field | Rules |
|---|---|
url |
Required. A valid URL, up to 2048 characters. |
events |
Required. At least one event name from the table below, or * for all events. |
description |
Optional. Up to 255 characters. |
Response (201):
{
"data": {
"id": 1,
"url": "https://example.com/freedam/webhook",
"description": "Production pipeline",
"events": ["asset.created", "asset.updated", "share.created"],
"is_active": true,
"secret": "the-signing-secret",
"created_at": "2026-05-06T10:00:00+00:00"
},
"message": "Webhook created. Save the secret - it will not be shown again."
}
Store secret server-side. It is never returned again. If you lose it, call POST /v1/webhooks/{id}/rotate-secret.
Event payload shape
Every delivery body has the same envelope. data depends on the event type.
{
"event": "asset.updated",
"timestamp": "2026-05-06T10:00:00+00:00",
"data": {
"gaid": "IM-20260506-UDXZH8PP6VPW",
"changed_fields": ["title", "description"]
}
}
Payloads carry identifiers, not full records: assets are identified by their GAID, as everywhere in the API. Fetch the current state with the REST API (for example GET /v1/assets/{gaid}) when you need more.
Available events
| Event | Triggered when | data fields |
|---|---|---|
asset.created |
A new asset finishes ingestion and business rules. | gaid |
asset.updated |
An asset's metadata changes. | gaid, changed_fields |
asset.deleted |
An asset is moved to the trash or permanently deleted. | gaid, hard_delete |
asset.downloaded |
A user downloads an asset. | gaid, resolution |
collection.created |
A collection is created. | collection_id, name |
collection.updated |
A collection changes. | collection_id, name, changed_fields |
collection.deleted |
A collection is deleted. | collection_id, name |
collection.assets_added |
Assets are added to a collection, manually or by a collection rule. | collection_id, asset_gaids |
collection.assets_removed |
Assets are removed from a collection. | collection_id, asset_gaids |
share.created |
A share link is created, in Freedam or through the API. | share_id, share_token |
share.accessed |
Someone opens a share link or downloads from it. | share_id, action (view or download) |
upload.batch_completed |
An upload batch finishes with at least one successful file. | batch_id, success_count, failure_count |
upload.batch_failed |
Every file in an upload batch failed. | batch_id, reason |
workflow.transitioned |
An asset moves to another workflow status. | gaid, from_status, to_status |
webhook.test |
Sent by POST /v1/webhooks/{id}/test to that endpoint only. |
message |
Subscribe to * to receive every event, including events added later. webhook.test is not a subscribable event: POST /v1/webhooks/{id}/test sends it to that endpoint only, whatever its events list.
Verifying signatures
Every delivery includes these headers:
| Header | Value |
|---|---|
X-Freedam-Signature-256 |
Hex-encoded HMAC-SHA256 of {timestamp}.{raw-body}. |
X-Freedam-Timestamp |
Unix time in seconds when the delivery attempt was signed. |
Content-Type |
application/json |
To verify a request:
- Read the raw request body before any JSON parsing.
- Compute
HMAC-SHA256(secret, "{X-Freedam-Timestamp}.{raw-body}")and hex-encode it. - Compare it to
X-Freedam-Signature-256with a constant-time comparison. - Reject the request if the timestamp is more than 5 minutes away from your clock. This blocks replayed requests.
Each retry is signed again with a fresh timestamp, so a retried delivery still passes the freshness check.
TypeScript SDK
verifyWebhookSignature is async and throws FreedamSignatureError when the signature or timestamp is invalid.
import { verifyWebhookSignature, FreedamSignatureError } from '@freedam/sdk';
try {
await verifyWebhookSignature(
rawBody,
request.headers['x-freedam-signature-256'],
request.headers['x-freedam-timestamp'],
process.env.FREEDAM_WEBHOOK_SECRET!,
{ maxAgeSec: 300 },
);
} catch (err) {
if (err instanceof FreedamSignatureError) {
return new Response('Invalid signature', { status: 401 });
}
throw err;
}
Node.js without the SDK
import { createHmac, timingSafeEqual } from 'node:crypto';
function isValidFreedamWebhook(rawBody: string, signature: string, timestamp: string, secret: string): boolean {
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) {
return false;
}
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
PHP
$timestamp = $_SERVER['HTTP_X_FREEDAM_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_FREEDAM_SIGNATURE_256'] ?? '';
$rawBody = file_get_contents('php://input');
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
if (abs(time() - (int) $timestamp) > 300 || ! hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
Retries and automatic disabling
| Attempt | Sent |
|---|---|
| 1 | As soon as the event occurs. |
| 2 | 10 seconds after attempt 1. |
| 3 | 1 minute after attempt 2. |
| 4 | 5 minutes after attempt 3. |
| 5 | 30 minutes after attempt 4. Last attempt. |
- Every failed attempt adds one to the endpoint's
failure_count. Any successful delivery resets it to 0. - When
failure_countreaches 10, the endpoint is set tois_active: falseand stops receiving events. Events that occur while it is disabled are not queued for later. - To bring an endpoint back, fix your receiver, then click Reset Failures in Admin → API → Webhooks. This sets
failure_countto 0 and re-activates the endpoint. Settingis_active: truewithPUTalso re-activates it, but keeps the failure count, so the next failed attempt disables it again.
Because retries happen, the same event can reach you more than once. Make your handler idempotent, for example by ignoring an asset.updated you have already processed for the same asset_id and timestamp.
Inspecting deliveries
GET /v1/webhooks/{id}/deliveries returns recent delivery records, newest first. Each record includes event_type, payload, attempts, response_status, the first 1000 characters of your response body (or the connection error), delivered_at (set on success), and next_retry_at (set while a retry is pending).
The same health data appears in Admin → API, on the Webhooks tab and in the Overview's webhook health chart.
Disabling and rotating
- Pause an endpoint by sending
is_active: falsewithPUT. The URL and event list are kept. - Rotate the secret when a teammate leaves or after a suspected leak. The response contains the new
secretandprevious_secret_expires_at, 24 hours later. Deliveries sent after the rotation are signed with the new secret, so deploy it to your receiver right away. If you need a switch without downtime, accept either secret in your receiver until the deploy is done.