Receiving webhooks
Receive webhooks from outside services through a relay endpoint that verifies senders before anything reaches your app.
Your app can receive webhooks from outside services — a payment completed, a GitHub push.
Only the AxHub receive endpoint's address is exposed outside, and only verified requests reach your app.
How it works
outside service → receive endpoint (hooks.{company}… /r/{endpoint id}) → sender verification → your app's route- The endpoint address is separate from your app's address. Redeploy the app or change internal paths, and the address you handed out stays the same.
- Only verified requests reach the app, with the original method and body preserved. The query string is not forwarded to the app.
What you need
- A connected app — webhooks are received per app. If you don't have one yet, start with Build and deploy your app.
- CLI login — endpoints are managed with the
axhub relaycommands.
Create a receive endpoint
axhub relay create --app <app> --name <endpoint-name> --verify-mode keyThe output shows the receive address, the key, and the receive contract. That output is the authority.
The key is shown only this once. If you lose it, issue a new one with axhub relay rotate-key.
Pick the sender verification (--verify-mode) that matches the outside service.
| verify-mode | What the sender provides |
|---|---|
key (default) | The shared key in an X-AxHub-Key header |
hmac | HMAC-SHA256 of the raw body as X-Signature-256: sha256=<hex> |
none | No verification. Only for senders you trust |
Receive it in your app
The receiving side is not the SDK — it's a plain route handler in your framework. In Next.js it looks like this.
import { createHmac, timingSafeEqual } from 'crypto';
export async function POST(req: Request): Promise<Response> {
const secret = process.env.AXHUB_RELAY_SIGNING_SECRET ?? '';
const rawBody = await req.text();
const provided = req.headers.get('x-axhub-signature') ?? '';
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
const ok = secret !== '' && provided.length === expected.length &&
timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!ok) {
return new Response('unauthorized', { status: 401 });
}
const deliveryId = req.headers.get('x-axhub-delivery') ?? '';
// Retries can deliver the same event again — handle it once, keyed by deliveryId.
console.log('inbound webhook', deliveryId, rawBody.length);
// Return 2xx once handled; a durable endpoint redelivers anything else.
return new Response('ok', { status: 200 });
}- Every delivery carries an
X-AxHub-Signature: sha256=<hex>signature. Recompute the HMAC-SHA256 of the raw body with theAXHUB_RELAY_SIGNING_SECRETenvironment variable, injected automatically at deploy time, and only requests that came through the receive endpoint get through. Nothing to configure. - Every delivery also carries an
X-AxHub-Deliveryheader (a unique delivery id). Retries can bring the same delivery twice, so make handling idempotent on that value. - The key is used only between the sender and the receive endpoint. Once verification passes, the endpoint strips credential headers like
X-AxHub-Keybefore forwarding, so don't check the key again in your app.
If your app has several services
An app deployed with docker compose receives webhooks on its entry service, the same one that answers when you open the app's address.
- The entry service is the first service that publishes a host port in the compose file. You can also name it directly with
runtime.entry_serviceinaxhub.yaml. - Changing the entry service keeps the receive endpoint as it is. There is no need to recreate it, and the next deploy delivers to the new service.
- If no service publishes a port, there is nowhere to deliver, so the endpoint cannot be created. Naming a service with
runtime.entry_servicesolves it.
The rules for picking the entry service are covered in the axhub.yaml reference.
Receiving on staging
You can try an integration against your staging deployment before pointing it at production. There is no second endpoint to create.
If the app uses staging, one endpoint has two addresses.
production hooks.{company}.axhub.ai/r/{endpoint id} → production deployment
staging hooks--staging.{company}.axhub.ai/r/{endpoint id} → staging deployment- It follows the same rule as app addresses splitting into
{app}--staging.{company}. The address a request arrives on decides which deployment receives it. - Keys are per address. A staging key handed to a test sender cannot be used against the production address.
- Both addresses appear in
axhub relay createoutput and inaxhub relay list. To rotate just one of them, pick the environment:axhub relay rotate-key <endpoint id> --app <app> --environment staging --execute. - The delivery history records which address each request came in on.
An app that does not use staging gets no staging address. Turn staging on in the app settings first.
Delivery and retries
| Delivery mode | Behavior |
|---|---|
durable (default) | Redelivers with growing backoff until your app returns 2xx (1m → 5m → 30m → 2h → 6h, then marked failed) |
sync | Passes your app's response straight back to the sender, including the status, the body, and response headers such as Content-Type |
A sync endpoint is done once your app answers. The sender already has that response, so it is not sent again, and whether to try once more is up to the sender. Only a delivery that never reached the app is retried on the intervals above.
Request bodies default to 1MiB (413 beyond that). Intake defaults to 300 per minute, and going over returns 429.
Operating it
| What you want | Command |
|---|---|
| List endpoints | axhub relay list --app <app> |
| See deliveries (status, attempts, response code) | axhub relay deliveries <endpoint id> --app <app> |
| Resend a failed delivery | axhub relay replay <endpoint id> <delivery id> --app <app> --execute |
| Rotate the key | axhub relay rotate-key <endpoint id> --app <app> --execute |
| Rotate only the staging key | axhub relay rotate-key <endpoint id> --app <app> --environment staging --execute |
| Close an endpoint (irreversible) | axhub relay delete <endpoint id> --app <app> --execute |
You've succeeded when
Put the receive address and key into the outside service's webhook settings and send a test event.
If axhub relay deliveries records the delivery with a 2xx response code, you're done.