Axuz Developers

Webhooks

Receive Axuz events on your server, verify the HMAC signature, handle retries and debug deliveries.

A webhook makes Axuz send a JSON POST to a URL of yours whenever something happens in the workspace (a task is created, a file is uploaded, a post is published). This page covers registration, the message format, signature verification, retries and debugging.

There are two systems in the code today, with different signatures:

Integrations Center (new)Workspace webhooks (legacy)
RegistrationPOST /teams/{teamId}/integrations/{id}/webhooksPOST /teams/{teamId}/webhooks
Signature headerX-Axuz-Signature: t=<unix>,v1=<hex>X-Axuz-Signature: sha256=<hex>
What is signed"<t>." + bodythe body only
Anti-replaysigned timestamp + 5 min windownone
Delivery idX-Axuz-Delivery-IdX-Axuz-Delivery
Secretgenerated by Axuz (whsec_...), shown oncechosen by you

The sections below describe the new system. The section Legacy workspace webhooks describes the other one.

Register an endpoint

Webhooks belong to a team integration. Create the integration and then the outbound webhook. Management requires a session of a team owner or admin; integration tokens (axz_bot_*) never manage integrations.

  1. Create the integration with POST /teams/{teamId}/integrations.
  2. Create the webhook with direction: "out", the https URL and the events.
  3. Save the secret from the response: it appears only once.

curl

curl -sS -X POST "https://api.axuz.net/teams/$TEAM_ID/integrations/$INTEGRATION_ID/webhooks" \
  -H "Authorization: Bearer $AXUZ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"direction":"out","url":"https://hooks.example.test/axuz","events":["task.created","task.moved"]}'

Node

const res = await fetch(`https://api.axuz.net/teams/${teamId}/integrations/${integrationId}/webhooks`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.AXUZ_TOKEN}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ direction: 'out', url: 'https://hooks.example.test/axuz', events: ['task.created', 'task.moved'] }),
});
const hook = await res.json(); // hook.secret: store it now, it will not be shown again

Python

import json, os, urllib.request

req = urllib.request.Request(
    f"https://api.axuz.net/teams/{team_id}/integrations/{integration_id}/webhooks",
    data=json.dumps({"direction": "out", "url": "https://hooks.example.test/axuz", "events": ["task.created"]}).encode(),
    headers={"Authorization": f"Bearer {os.environ['AXUZ_TOKEN']}", "Content-Type": "application/json"},
    method="POST",
)
hook = json.load(urllib.request.urlopen(req))  # hook["secret"]: store it now

The response is 201 with the webhook (id, direction, url, events, active, secret_version) plus the secret field (whsec_ + 40 characters). Listings and GET never return the secret. Each integration accepts at most 10 webhooks.

To change one: PATCH .../webhooks/{webhookId} (fields url, events, active; at least one) and DELETE to remove it (204).

Event types

The subscribable events in this system are: task.created, task.updated, task.moved, task.deleted, task.shared, task.unshared, task.commented, task.batch, timeline.posted, timeline.post.edited, timeline.post.deleted, thread.updated and file.uploaded. Financial events cannot be subscribed to and the * wildcard does not exist here. An event outside the list returns 400 invalid.

Rejected URLs (SSRF protection)

On registration (and on PATCH) the URL is rejected with 400 when:

  • the scheme is not https;
  • credentials are embedded (https://user:pass@host);
  • the host is localhost or *.localhost, a literal loopback IP, a private network (10/8, 172.16/12, 192.168/16), link-local (169.254/16, which includes the cloud metadata endpoint) or the unspecified address;
  • the host ends in .internal, .local or .localdomain, or is metadata;
  • the URL contains a space or a control character.

That is only the first layer. At send time the worker resolves DNS again and blocks if any resolved IP is internal (loopback, private, link-local, CGNAT 100.64/10, reserved ranges, IPv4 mapped into IPv6), connects straight to the already validated IP (no second lookup, which closes DNS rebinding), ignores environment proxies and never follows redirects: a 3xx counts as a failed delivery. Your endpoint must therefore answer 2xx at the registered URL, without redirecting.

Message format

Each delivery is a POST with Content-Type: application/json and User-Agent: Axuz-Integrations/1.0.

HeaderContent
X-Axuz-Eventevent type, for example task.created
X-Axuz-Delivery-Iddelivery id (UUID). Constant across retries and manual redeliveries
X-Axuz-Attemptattempt number, starting at 1
X-Axuz-Signaturet=<unix>,v1=<hex>; the timestamp is renewed on every attempt

The body is the event envelope:

{
  "id": "evt_1",
  "type": "task.created",
  "team_id": "22222222-2222-2222-2222-222222222222",
  "actor_id": "33333333-3333-3333-3333-333333333333",
  "created_at": "2030-03-17T17:46:40Z",
  "data": { "title": "Review proposal" }
}

id identifies the event (Axuz queues a single delivery per webhook and event). data is the domain event payload and varies by type; the fields of each type are not documented here yet.

Verify the signature

The header is X-Axuz-Signature: t=<unix>,v1=<hex> and

v1 = hex( HMAC-SHA256( secret, "<t>." + raw_body ) )

that is, the timestamp text, a dot and the exact bytes of the body as they arrived. To verify:

  1. Read the raw body before calling JSON.parse; re-serializing changes the bytes and breaks the signature.
  2. Extract t and all v1 values from the header (there may be more than one v1; accept if any matches).
  3. Recompute the HMAC with the secret and compare in constant time.
  4. Reject if |now - t| > 300 seconds (past or future): this blocks replay of an old capture.
  5. Deduplicate by X-Axuz-Delivery-Id to stop replay inside the window and redeliveries.

Node

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(secret, header, rawBody, now = Math.floor(Date.now() / 1000)) {
  const parts = header.split(',').map((p) => p.split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!Number.isInteger(t) || t <= 0 || sigs.length === 0) return false;

  const want = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
  const ok = sigs.some((s) => s.length === want.length && timingSafeEqual(Buffer.from(s.toLowerCase()), Buffer.from(want)));
  return ok && Math.abs(now - t) <= 300;
}

Python

import hashlib, hmac, time


def verify(secret: str, header: str, raw_body: bytes, now: int | None = None) -> bool:
    now = int(time.time()) if now is None else now
    fields = [p.partition("=") for p in header.split(",")]
    ts = [v for k, _, v in fields if k.strip() == "t"]
    sigs = [v for k, _, v in fields if k.strip() == "v1"]
    if len(ts) != 1 or not ts[0].isdigit() or not sigs:
        return False
    t = int(ts[0])
    want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    ok = any(hmac.compare_digest(s.lower(), want) for s in sigs)
    return ok and abs(now - t) <= 300

The complete, tested files are in examples/webhooks/verify.mjs (it includes a node:http receiver that deduplicates by X-Axuz-Delivery-Id) and examples/webhooks/verify.py, with vectors for a valid signature, a tampered body, an old timestamp and a malformed header.

Secret and rotation

The secret is generated by Axuz (whsec_ + 40 base62 characters, CSPRNG) and stored encrypted. To rotate it, call POST .../webhooks/{webhookId}/rotate-secret: the response carries the new secret (once) and secret_version goes up. The old secret stops being valid immediately, so the next deliveries are already signed with the new one; update the secret in your receiver right after rotating. During the switch some deliveries may fail verification and will be retried by the backoff.

Responses, timeout and retries

  • Success: any 2xx. The response body is stored truncated (1 KiB) for debugging only.
  • Failure: network error, 30 s timeout, any non-2xx (including 3xx).
  • Backoff after failure number N: 30 s, 2 min, 10 min, 1 h, 6 h, 24 h and 72 h.
  • Giving up: with max_attempts = 8, the 8th failure moves the delivery to dead_letter. It is no longer retried automatically; requeue it with redeliver (below).
  • The new system does not read Retry-After.
  • Outside production (AXUZ_ENV other than prd/prod/production, including unset) nothing leaves: the delivery becomes sunk when the destination is a test one (*.test, *.invalid, *.example, RFC 2606 example domains or test.<domain>) and blocked for any other destination.

Answer 2xx fast (write to a database or queue and process later): if your processing takes more than 30 s, Axuz counts it as a failure and resends, and you process the same event twice.

Idempotency

Delivery is at least once. The same X-Axuz-Delivery-Id comes back on every retry and every redeliver. Store the ids you already processed (with a TTL longer than the retry window, 72 h or more) and answer 2xx without reprocessing when one repeats. The event id (id in the body) is also stable.

Debug deliveries

GET/teams/{teamId}/integrations/{id}/deliveries

Filters: webhook_id, status, limit (default 50, maximum 100) and cursor (opaque cursor pagination; the response carries next_cursor). Statuses: pending, delivering, delivered, dead_letter, blocked, sunk and received (inbound). Each item carries attempts, max_attempts, next_attempt_at, last_status_code, last_response (truncated) and last_error (without the URL, which may carry a token). Secrets never appear.

To resend a terminal delivery (dead_letter, blocked, sunk or delivered):

POST/teams/{teamId}/integrations/{id}/deliveries/{deliveryId}/redeliver

It returns 202, resets the attempts and keeps the same X-Axuz-Delivery-Id. pending or delivering deliveries answer 409.

Typical causes: non_2xx_status_500 (your server failed), invalid_signature on your side (re-serialized body or old secret), destination_blocked (the URL resolves to an internal IP), timeout (slow synchronous processing).

Best practices

  • Answer 2xx within a few seconds and process asynchronously.
  • Verify the signature before anything else and reject with 401.
  • Compare in constant time and enforce the 5 minute window (keep your server clock synced with NTP).
  • Deduplicate by X-Axuz-Delivery-Id.
  • Keep the secret in an environment variable or vault, never in the repository or the front end.
  • Rotate the secret if it leaks.

Legacy workspace webhooks

The endpoints under /teams/{teamId}/webhooks are live and keep working. They require a team owner/admin session.

POST/teams/{teamId}/webhooks

Body: name, url, secret (optional, chosen by you), events (list from the GET /webhooks/event-types catalog, * accepted), is_active and format (raw or slack; slack requires a https://hooks.slack.com/services/... URL). An event outside the catalog returns 422 unknown_event_type. The URL goes through the same SSRF rules as the registration above, but without revalidation at send time and without redirect blocking; only use URLs you control.

To rotate, call PUT /teams/{teamId}/webhooks/{webhookId} with the new secret (immediate effect). The listing (GET) returns the secret in clear text to owners/admins.

Delivery: POST with Content-Type: application/json, User-Agent: Axuz-Webhook/1.0 and the headers X-Axuz-Event, X-Axuz-Delivery (delivery id, use it to deduplicate), X-Axuz-Attempt and, only if a secret is set, X-Axuz-Signature: sha256=<hex> where the hex is HMAC-SHA256(secret, raw_body). There is no timestamp: there is no signature-based replay protection, so deduplicate by X-Axuz-Delivery. The body is:

{
  "event": "task.created",
  "team_id": "22222222-2222-2222-2222-222222222222",
  "data": { "task": {} },
  "timestamp": "2030-03-17T17:46:40Z"
}

With format: "slack" the body becomes {"text": "..."} and the signature is computed over those bytes.

Retries: 30 s timeout per attempt; 2xx is success; any other response or error schedules a new attempt with backoff 30 s, 2 min, 10 min, 1 h, 6 h, 24 h, 3 d (and 7 d if there were more attempts), and max_attempts = 8 leads to dead_letter. On 429 and 503 the Retry-After header (seconds or HTTP date, capped at 7 days) replaces the backoff. Deliveries stuck in delivering by a worker that died go back to pending after 5 minutes.

Verification in the legacy format:

Node

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyLegacy(secret, header, rawBody) {
  const want = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
  return header.length === want.length && timingSafeEqual(Buffer.from(header), Buffer.from(want));
}

Python

import hashlib, hmac


def verify_legacy(secret: str, header: str, raw_body: bytes) -> bool:
    want = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(header, want)

Debugging (legacy):

See also the webhooks reference and Authentication.

Updated