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#
| Type | When |
|---|---|
payment.created | The payment was created. |
payment.waiting, payment.confirming, payment.partially_paid, payment.finished, payment.expired, payment.failed, payment.refunded | The payment's status changed. payment.waiting follows a reorganization. |
payment.credited | Funds were credited to your balance. |
payment.reversed | A credit was reversed after a reorganization. |
payment.deposit_accepted | A 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.test | A 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.finished | A 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=1204f3bf41b7da3944bf68b2275b99ef419986be5adefc3c8e2668081996a50ev1 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:
- Use the raw body bytes you received. Parsing and re-serializing the JSON changes them and breaks the signature.
- Reject a timestamp more than 5 minutes from your clock.
- Compute the HMAC with each secret you accept and compare it with every
v1value in constant time. Ignore other schemes, such asv0.
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);body = self.rfile.read(int(self.headers["Content-Length"])) # the raw body, exactly as received
signature = self.headers["Nowgate-Signature"]
try:
event = Webhook.construct_event(body, signature, secret)
except SignatureVerificationError:
self.send_response(400)
self.end_headers()
return$payload = (string) file_get_contents('php://input'); // the raw body, exactly as received
$header = $_SERVER['HTTP_NOWGATE_SIGNATURE'] ?? null;
try {
$event = Webhook::constructEvent($payload, is_string($header) ? $header : null, $secret);
} catch (SignatureVerificationException $e) {
http_response_code(400);
return;
}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#
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";
}import hashlib
import hmac
import re
import time
class SignatureError(Exception):
"""Raised with the reason: malformed, no_signature, timestamp or mismatch."""
def verify_nowgate_signature(header, raw_body, secrets, tolerance=300, now=None):
"""Verifies a Nowgate-Signature header against the raw request body (bytes).
Pass the exact bytes you received, before any JSON parsing, and every endpoint
secret you accept (two while rotating). Raises SignatureError if the event is
not genuine.
"""
timestamp = None
signatures = []
for part in header.split(","):
key, sep, value = part.strip().partition("=")
if not sep:
raise SignatureError("malformed")
if key == "t":
if timestamp is not None or not re.fullmatch(r"[0-9]+", value) or int(value) <= 0:
raise SignatureError("malformed")
timestamp = int(value)
elif key == "v1":
if not re.fullmatch(r"[0-9a-fA-F]{64}", value):
raise SignatureError("malformed")
signatures.append(bytes.fromhex(value))
# other schemes are ignored
if timestamp is None:
raise SignatureError("malformed")
if not signatures:
raise SignatureError("no_signature")
now = int(time.time()) if now is None else now
if abs(now - timestamp) > tolerance:
raise SignatureError("timestamp")
match = False
for secret in secrets:
message = str(timestamp).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).digest()
for signature in signatures:
match = hmac.compare_digest(expected, signature) or match # no early exit
if not match:
raise SignatureError("mismatch")<?php
declare(strict_types=1);
/** Thrown with the reason: malformed, no_signature, timestamp or mismatch. */
final class SignatureError extends Exception
{
}
/**
* Verifies a Nowgate-Signature header against the raw request body.
*
* Pass the exact bytes you received, before any JSON parsing (file_get_contents('php://input')),
* and every endpoint secret you accept (two while rotating). Throws SignatureError if the event
* is not genuine.
*
* @param list<string> $secrets
*/
function verify_nowgate_signature(string $header, string $rawBody, array $secrets, int $tolerance = 300, ?int $now = null): void
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
throw new SignatureError('malformed');
}
[$key, $value] = $pair;
if ($key === 't') {
if ($timestamp !== null || preg_match('/\A[0-9]+\z/', $value) !== 1 || (int) $value <= 0) {
throw new SignatureError('malformed');
}
$timestamp = (int) $value;
} elseif ($key === 'v1') {
if (preg_match('/\A[0-9a-fA-F]{64}\z/', $value) !== 1) {
throw new SignatureError('malformed');
}
$signatures[] = (string) hex2bin($value);
} // other schemes are ignored
}
if ($timestamp === null) {
throw new SignatureError('malformed');
}
if ($signatures === []) {
throw new SignatureError('no_signature');
}
$now ??= time();
if (abs($now - $timestamp) > $tolerance) {
throw new SignatureError('timestamp');
}
$match = false;
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret, true);
foreach ($signatures as $signature) {
$match = hash_equals($expected, $signature) || $match; // no early exit
}
}
if (!$match) {
throw new SignatureError('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.versionyou'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
2xxwithin 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 Goneresponse 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
idand 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_urlis 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.