Webhooks

Verify Amboss Payments webhooks with the SDK. Instance and static APIs, raw-body handling for Express, NestJS, and Fetch, and typed error codes.

The SDK verifies webhook signatures for you. Give it the raw body, the signature, and the timestamp; it checks the HMAC-SHA256 signature in constant time and returns a typed PaymentEvent. No manual crypto, no timing-attack footguns.

Verify every webhook before acting on it. An unverified webhook is just an HTTP POST from anyone on the internet. For the protocol details, headers, retries, and replay protection, see Verify Webhooks.

Instance API

Construct a client with a webhookSecret, then verify each delivery:

const payments = new Payments({
  webhookSecret: process.env.AMBOSS_WEBHOOK_SECRET,
});

const event = payments.webhooks.verify({
  payload, // string | Buffer, the RAW request body
  signature, // x-webhook-signature header value
  timestamp, // x-webhook-timestamp header value
  toleranceSeconds: 300, // optional; default 300
});

event.event_type; // 'payment.pending' | 'payment.completed' | 'payment.failed'
event.data.id;

Static API

For stateless verification without constructing a client, pass the secret inline:

import { Payments } from "@ambosstech/payments";

const event = Payments.webhooks.verify({
  secret: process.env.AMBOSS_WEBHOOK_SECRET,
  payload,
  signature,
  timestamp,
});

Raw body matters

The HMAC is computed over `${timestamp}.${rawBody}`. If your framework parses JSON before your handler runs, the re-serialized bytes will not match and verification fails. Capture the raw body before any parser runs.

app.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const event = payments.webhooks.verify({
      payload: req.body, // Buffer, because of express.raw
      signature: req.header("x-webhook-signature")!,
      timestamp: req.header("x-webhook-timestamp")!,
    });

    // dispatch on event.event_type, dedupe on event.id
    res.sendStatus(200);
  },
);
// Enable raw body capture when creating the app:
// const app = await NestFactory.create(AppModule, { rawBody: true });

import { Payments, WebhookVerificationError } from "@ambosstech/payments";

@Post("webhook")
handle(
  @Req() req: RawBodyRequest<Request>,
  @Headers() headers: Record<string, string>,
) {
  try {
    const event = this.payments.webhooks.verify({
      payload: req.rawBody!,
      signature: headers["x-webhook-signature"],
      timestamp: headers["x-webhook-timestamp"],
    });
    return { ok: true, event };
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      throw new BadRequestException(error.code);
    }
    throw error;
  }
}
export async function POST(request: Request) {
  const rawBody = await request.text();

  const event = payments.webhooks.verify({
    payload: rawBody,
    signature: request.headers.get("x-webhook-signature")!,
    timestamp: request.headers.get("x-webhook-timestamp")!,
  });

  // dispatch on event.event_type, dedupe on event.id
  return new Response(null, { status: 200 });
}

The event payload

verify returns a typed PaymentEvent. The envelope carries routing fields; data carries the payment:

event.id; // envelope id; dedupe on this
event.event_type; // 'payment.pending' | 'payment.completed' | 'payment.failed'
event.environment; // 'sandbox' | 'production'
event.wallet_id;

event.data.status; // 'pending' | 'completed' | 'failed'
event.data.direction; // 'send' | 'receive'
event.data.amount; // { amount, asset_id, asset_symbol, precision }
event.data.settled_at;
event.data.payment_details.payment_hash;

Error codes

Every verification failure throws a WebhookVerificationError with a typed code:

CodeMeaning
missing_secretConstructor webhookSecret was not provided (instance API).
missing_signatureThe signature parameter was empty.
missing_timestampThe timestamp parameter was empty.
invalid_timestamptimestamp is not numeric.
timestamp_out_of_toleranceClock skew exceeded toleranceSeconds.
invalid_signature_formatSignature is not valid hex or has the wrong length.
signature_mismatchHMAC did not match: wrong secret, tampered body, or wrong payload bytes.
invalid_payload_jsonSignature verified but the body is not valid JSON.
import { WebhookVerificationError } from "@ambosstech/payments";

try {
  const event = payments.webhooks.verify({ payload, signature, timestamp });
} catch (error) {
  if (error instanceof WebhookVerificationError) {
    // error.code is one of the values above
    return res.status(401).send(error.code);
  }
  throw error;
}

Signature algorithm

For reference, the SDK computes:

expected = HMAC_SHA256(secret, `${timestamp}.${rawBody}`)
header   = expected.toString('hex')

Comparison uses crypto.timingSafeEqual.

Next steps