NowgateDocs

Webhooks

Receive every payment change as a signed event, verify it, and handle retries and ordering.

On this page

Nowgate POSTs an event to your endpoint every time a payment, payout or refund changes. Your endpoint must answer with any 2xx status to acknowledge it.

Events#

TypeWhen
payment.createdThe payment was created.
payment.waiting, payment.confirming, payment.partially_paid, payment.finished, payment.expired, payment.failed, payment.refundedThe payment's status changed. payment.waiting follows a reorganization.
payment.creditedFunds were credited to your balance.
payment.reversedA credit was reversed after a reorganization.
payment.deposit_acceptedA stuck deposit was accepted into your balance: the wrong coin, or coin that came after the payment failed. The payment's status doesn't change.
ping.testA test sent from the dashboard to this endpoint alone. Nothing happened.
payout.created, payout.<status>, refund.created, refund.<status>, destination.created, destination.active, destination.disabled, batch.created, batch.finishedA payout, refund, destination or batch was created or changed: see Payouts and Refunds.

Every event has the same envelope. data.object is the payment exactly as GET /v1/payments/{id} returns it at that moment (or the payout, refund, destination or batch):

{
  "id": "evt_01jc3x9w4qv7m2c8d5t0y6k1hr",
  "object": "event",
  "type": "payment.finished",
  "api_version": "2026-11-01",
  "environment": "live",
  "created_at": "2026-11-05T12:00:00Z",
  "data": { "object": { "id": "pay_01jc3x6k2tf8yq0b5n2c7m9d4v", "status": "finished", "version": 4 } }
}

Requests also carry Nowgate-Event-Id, Nowgate-Event-Type, Nowgate-Delivery-Id and Nowgate-Delivery-Attempt headers, and User-Agent: Nowgate-Webhooks/1.

Verify the signature#

Each request is signed in the Nowgate-Signature header:

Nowgate-Signature: t=1793880000,v1=1204f3bf41b7da3944bf68b2275b99ef419986be5adefc3c8e2668081996a50e

v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your endpoint secret (whsec_…). t is the time of the attempt, so every retry is signed afresh. To verify:

  1. Use the raw body bytes you received. Parsing and re-serializing the JSON changes them and breaks the signature.
  2. Reject a timestamp more than 5 minutes from your clock.
  3. Compute the HMAC with each secret you accept and compare it with every v1 value in constant time. Ignore other schemes, such as v0.

With an SDK#

Each SDK verifies exactly as Nowgate's own verifier does, with no client needed, and returns the event, told apart by its type. It takes the raw body: Express's express.raw(), a Next.js route's await request.text(), Flask's request.get_data(), Django's request.body, Laravel's $request->getContent(), or plain PHP's file_get_contents('php://input').

const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(chunk as Buffer);
const body = Buffer.concat(chunks); // the raw body, exactly as received
const signature = request.headers["nowgate-signature"];
const event = await Webhooks.constructEvent(body, signature, secret);

A signature that doesn't verify raises the SDK's signature error, whose reason is malformed, no_signature, timestamp or mismatch. Answer it with a 4xx, and never act on the body.

Without an SDK#

verify-webhook.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export type VerifyError = "malformed" | "no_signature" | "timestamp" | "mismatch";

/**
 * Verifies a Nowgate-Signature header ("t=<unix seconds>,v1=<hex HMAC-SHA256>")
 * against the raw request body. Pass the exact bytes you received, before any JSON
 * parsing, and every endpoint secret you accept (two while rotating).
 * Returns null when the event is genuine, or the reason it is not.
 */
export function verifyNowgateSignature(
  header: string,
  rawBody: Buffer | string,
  secrets: string[],
  { toleranceSeconds = 300, now = Math.floor(Date.now() / 1000) } = {},
): VerifyError | null {
  let timestamp = 0;
  const signatures: Buffer[] = [];
  for (const part of header.split(",")) {
    const item = part.trim();
    const eq = item.indexOf("=");
    if (eq < 0) return "malformed";
    const key = item.slice(0, eq);
    const value = item.slice(eq + 1);
    if (key === "t") {
      if (timestamp !== 0 || !/^[0-9]+$/.test(value) || Number(value) <= 0) return "malformed";
      timestamp = Number(value);
    } else if (key === "v1") {
      if (!/^[0-9a-fA-F]{64}$/.test(value)) return "malformed";
      signatures.push(Buffer.from(value, "hex"));
    } // other schemes are ignored
  }
  if (timestamp === 0) return "malformed";
  if (signatures.length === 0) return "no_signature";
  if (Math.abs(now - timestamp) > toleranceSeconds) return "timestamp";

  const body = typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : rawBody;
  let match = false;
  for (const secret of secrets) {
    const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest();
    for (const signature of signatures) {
      match = timingSafeEqual(expected, signature) || match; // no early exit
    }
  }
  return match ? null : "mismatch";
}

These functions and the SDKs are tested against the published signature vectors, the same 21 cases Nowgate's own verifier passes. Run your implementation against them too.

With Express, read the raw body for the webhook route only:

import express from "express";
import { verifyNowgateSignature } from "./verify-webhook";

const app = express();
app.post("/webhooks/nowgate", express.raw({ type: "application/json" }), (req, res) => {
  const error = verifyNowgateSignature(req.get("Nowgate-Signature") ?? "", req.body, [process.env.NOWGATE_WEBHOOK_SECRET!]);
  if (error) return res.status(400).send(error);
  const event = JSON.parse(req.body.toString("utf8"));
  // Store event.id; skip it if you've seen it. Then act on event.type.
  res.sendStatus(204);
});

Handle events safely#

  • Deduplicate by the event id. The same event can arrive more than once.
  • Don't rely on order. Deliveries can arrive out of order. Keep the highest data.object.version you've seen per payment and ignore events with a lower one.
  • Re-check before you deliver. Before you ship goods, fetch the payment with GET /v1/payments/{id} and check its status.
  • Answer quickly. Respond 2xx within 10 seconds and do slow work afterwards. Redirects are not followed, and count as failures.

Retries#

A delivery that doesn't get a 2xx is retried 15 times in all, about 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 6 h and 8 h apart, then every 10 h, each with ±10 % jitter: about 72 hours in total.

  • A 410 Gone response stops the delivery and marks the endpoint gone.
  • An endpoint that fails continuously for 72 hours, with at least 15 failed attempts, is disabled.
  • Any event can be replayed later, from the dashboard's Developers pages, to one endpoint or all of them: it is sent again with the same id and body, signed afresh. Those pages also list each endpoint's deliveries, with every attempt's status, time and response.

Endpoints and secrets#

Endpoints are https:// URLs. Nowgate checks where a URL points at every delivery, after DNS resolution, and refuses private and internal addresses.

Each endpoint has its own secret, shown once when it is created or rotated. During a rotation, pass both the old and the new secret to your verifier until you've switched.

A URL per payment#

Pass webhook_url when you create a payment, and that payment's events go there instead of to your endpoints, for example to the system that sold that one order. The URL follows the endpoint rules, and every delivery passes the same check against private addresses.

  • Deliveries are signed with your default endpoint's secret, so you verify them like every other delivery. Without a default endpoint, webhook_url is refused.
  • The default is the one when the payment was created: changing it later doesn't change how that payment's events are signed.
  • The payment's events never go to your other endpoints, even if its URL fails until it is disabled, and replays go to its URL too.