Freedam

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

  1. Register an endpoint with POST /v1/webhooks, using a token with webhooks:manage and the ability of every event you subscribe to. The response contains the signing secret, shown once.
  2. Verify every incoming request with the X-Freedam-Signature-256 and X-Freedam-Timestamp headers (see below).
  3. Acknowledge with a 2xx response within 30 seconds. Freedam follows up to 3 redirects (see URL requirements). Any other status, a timeout, or a connection error counts as a failed attempt.
  4. Failed attempts are retried up to 5 attempts in total, waiting 10 seconds, 1 minute, 5 minutes, then 30 minutes between attempts.
  5. 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.

Every route needs the webhooks:manage ability. Choosing the events of an endpoint (POST, PUT), reading its deliveries and rotating its secret also need the ability of every event it subscribes to, listed in Available events, plus the permission behind that ability on your account. A token can only route events it could read through the REST API, because an endpoint keeps receiving them after the token that created it is revoked. Listing, reading, testing and deleting endpoints need webhooks:manage alone.

A missing ability returns 403 with the missing ones, before anything changes:

{
    "message": "Token does not have the required abilities.",
    "required_abilities": ["shares:read", "uploads:manage"]
}
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 http or https URL, up to 2048 characters, on a public host. See URL requirements.
events Required. At least one event name from the table below, or * for all events. The token needs the ability of each one, and every ability for *.
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.

URL requirements

Freedam only sends deliveries to hosts on the public internet. The URL is checked twice.

When you create or update an endpoint, POST and PUT return 422 for a URL that:

  • uses a scheme other than http or https;
  • has a private, loopback, link-local or reserved IP address as its host, for example 127.0.0.1, 10.0.0.5, 169.254.169.254 or [::1];
  • has a host name without a dot, such as localhost or webhook-receiver;
  • has a host name ending in a suffix used only on private networks: localhost, localdomain, local, internal, home.arpa, lan, intranet, corp or private.
{
    "message": "The url must point to a public internet address, not a private or internal host.",
    "errors": {
        "url": ["The url must point to a public internet address, not a private or internal host."]
    }
}

This check does not look up the host name in DNS.

At every delivery attempt, Freedam resolves the host name and refuses the delivery if any of its addresses is private, loopback, link-local or reserved, or if the name does not resolve. The connection then goes to the addresses that passed the check, so a DNS answer that changes between the check and the connection cannot redirect it to a private host. A refused delivery:

  • fails at once and is not retried;
  • adds one to the endpoint's failure_count;
  • records the reason in the delivery's response body, with no response_status.

This also applies to endpoints created before these rules existed: if one points to a private host, its deliveries are refused. Change its url with PUT.

Redirects. When your endpoint answers with a 3xx status and a Location header, Freedam checks the new URL the same way and sends the same POST there again, with the same body and signature headers. Up to 3 redirects are followed. A redirect to a private host is refused like any other, and after the third redirect the 3xx response is recorded as a failed attempt. Register the final URL of your receiver to avoid the extra requests.

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 Token abilities
asset.created A new asset finishes ingestion and business rules. gaid assets:read
asset.updated An asset's metadata changes. gaid, changed_fields assets:read
asset.deleted An asset is moved to the trash or permanently deleted. gaid, hard_delete assets:read
asset.downloaded A user downloads an asset. gaid, resolution assets:read
collection.created A collection is created. collection_id, name collections:read
collection.updated A collection changes. collection_id, name, changed_fields collections:read
collection.deleted A collection is deleted. collection_id, name collections:read
collection.assets_added Assets are added to a collection, manually or by a collection rule. collection_id, asset_gaids collections:read, assets:read
collection.assets_removed Assets are removed from a collection. collection_id, asset_gaids collections:read, assets:read
share.created A share link is created, in Freedam or through the API. share_id, share_token shares:read
share.accessed Someone opens a share link or downloads from it. share_id, action (view or download) shares:read
upload.batch_completed An upload batch finishes with at least one successful file. batch_id, success_count, failure_count uploads:manage
upload.batch_failed Every file in an upload batch failed, or the batch could not be processed. batch_id, reason uploads:manage
workflow.transitioned An asset moves to another workflow status. gaid, from_status, to_status assets:read
webhook.test Sent by POST /v1/webhooks/{id}/test to that endpoint only. message webhooks:manage

reason in upload.batch_failed is one of two fixed English sentences: All files in the batch failed processing when every file failed, and The batch could not be processed when the batch itself failed. It never contains the text of an internal error. Read the batch with GET /v1/uploads/batches/{batch} for its counters.

An upload batch ends once: a file that fails is retried first, and the event is sent when the last file has completed or failed for good. A batch that finished and then receives another file is processed again, so upload.batch_completed can be sent more than once for the same batch_id.

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.

Who receives an event

When an event happens, Freedam checks it against the permissions of each endpoint's owner, the way the REST API would answer that user, and only queues a delivery for owners allowed to see what the event is about:

Events The endpoint's owner must
asset.*, workflow.transitioned Be able to view the asset: assets.view, and no access rule hiding it from them.
collection.created, collection.updated, collection.deleted Be able to view the collection: collections.view, and a public collection, one they created, one shared with them, or any active collection for admin.users. A paused collection only reaches its creator, and a personal My Collections folder only its owner.
collection.assets_added, collection.assets_removed Be able to view the collection, as above. asset_gaids lists only the assets they can view, and no delivery is sent when they can view none of them.
share.* Have created the share link, or hold admin.shares.
upload.* Have started the upload batch.

Endpoints of deactivated users receive nothing. The check runs once, when the event happens: an event the owner could not see then is never delivered later, even if they gain access.

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:

  1. Read the raw request body before any JSON parsing.
  2. Compute HMAC-SHA256(secret, "{X-Freedam-Timestamp}.{raw-body}") and hex-encode it.
  3. Compare it to X-Freedam-Signature-256 with a constant-time comparison.
  4. 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.
  • A delivery refused because its URL resolves to a private address is not retried (see URL requirements).
  • When failure_count reaches 10, the endpoint is set to is_active: false and 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_count to 0 and re-activates the endpoint. Setting is_active: true with PUT also 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 gaid 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, or the reason a private URL was refused), 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: false with PUT. The URL and event list are kept.
  • Rotate the secret when a teammate leaves or after a suspected leak. The response contains the new secret and previous_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.

Try Freedam

Related Documentation

Explore more in this category