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 relay commands.

Create a receive endpoint

axhub relay create --app <app> --name <endpoint-name> --verify-mode key

The 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-modeWhat the sender provides
key (default)The shared key in an X-AxHub-Key header
hmacHMAC-SHA256 of the raw body as X-Signature-256: sha256=<hex>
noneNo 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.

app/api/hook/route.ts
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 the AXHUB_RELAY_SIGNING_SECRET environment 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-Delivery header (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-Key before 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_service in axhub.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_service solves 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 create output and in axhub 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 modeBehavior
durable (default)Redelivers with growing backoff until your app returns 2xx (1m → 5m → 30m → 2h → 6h, then marked failed)
syncPasses 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 wantCommand
List endpointsaxhub relay list --app <app>
See deliveries (status, attempts, response code)axhub relay deliveries <endpoint id> --app <app>
Resend a failed deliveryaxhub relay replay <endpoint id> <delivery id> --app <app> --execute
Rotate the keyaxhub relay rotate-key <endpoint id> --app <app> --execute
Rotate only the staging keyaxhub 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.