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) | |
|---|---|---|
| Registration | POST /teams/{teamId}/integrations/{id}/webhooks | POST /teams/{teamId}/webhooks |
| Signature header | X-Axuz-Signature: t=<unix>,v1=<hex> | X-Axuz-Signature: sha256=<hex> |
| What is signed | "<t>." + body | the body only |
| Anti-replay | signed timestamp + 5 min window | none |
| Delivery id | X-Axuz-Delivery-Id | X-Axuz-Delivery |
| Secret | generated by Axuz (whsec_...), shown once | chosen 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.
- Create the integration with
POST /teams/{teamId}/integrations. - Create the webhook with
direction: "out", thehttpsURL and the events. - Save the
secretfrom 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
localhostor*.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,.localor.localdomain, or ismetadata; - 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.
| Header | Content |
|---|---|
X-Axuz-Event | event type, for example task.created |
X-Axuz-Delivery-Id | delivery id (UUID). Constant across retries and manual redeliveries |
X-Axuz-Attempt | attempt number, starting at 1 |
X-Axuz-Signature | t=<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:
- Read the raw body before calling
JSON.parse; re-serializing changes the bytes and breaks the signature. - Extract
tand allv1values from the header (there may be more than onev1; accept if any matches). - Recompute the HMAC with the secret and compare in constant time.
- Reject if
|now - t| > 300seconds (past or future): this blocks replay of an old capture. - Deduplicate by
X-Axuz-Delivery-Idto 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(including3xx). - 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 todead_letter. It is no longer retried automatically; requeue it with redeliver (below). - The new system does not read
Retry-After. - Outside production (
AXUZ_ENVother thanprd/prod/production, including unset) nothing leaves: the delivery becomessunkwhen the destination is a test one (*.test,*.invalid,*.example, RFC 2606 example domains ortest.<domain>) andblockedfor 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):
/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
2xxwithin 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.
/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):
- GET
/teams/{teamId}/webhooks/endpoints/{webhookId}/deliverieswith?status=and?limit=(default 50, maximum 200). Statuses:pending,delivering,delivered,dead_letter. - GET
/teams/{teamId}/webhooks/deliveries/{deliveryId}returns the payload, the sent signature,last_response_code,last_response_bodyandlast_error. - POST
/teams/{teamId}/webhooks/deliveries/{deliveryId}/retryrequeues it for now (if it was exhausted, it gets one extra attempt). - DELETE
/teams/{teamId}/webhooks/deliveries/{deliveryId}deletes the record (useful if a payload leaked sensitive data).
See also the webhooks reference and Authentication.