Webhooks
Server-to-server delivery for everything that happens after the cardholder leaves.
Most of the API is synchronous: you POST a sale, you wait, you get a response. But some events happen after the cardholder has walked away — settlements, disputes, refunds clearing, terminals going offline. Atlas delivers these to your backend as signed HTTPS POSTs to a URL you configure in the dashboard.
Anatomy of a delivery
POST /atlas-webhook HTTP/1.1
Host: pos.example.com
Content-Type: application/json
Atlas-Event-Id: evt_01JQXYZW0001
Atlas-Event-Type: transaction.settled
Atlas-Delivery: 1
Atlas-Signature: t=1712572462,v1=8e1b8b9c2a4d4e9f9b1c7e2f8a4c2b3d...
Atlas-Webhook-Id: wh_01JQABC123
{ "id": "evt_01JQXYZW0001", "type": "transaction.settled", "data": { ... } }Atlas-Event-Id— globally unique event identifier — use it to dedupe.Atlas-Event-Type— the event type, e.g. transaction.settled.Atlas-Delivery— delivery attempt counter, starting at 1.Atlas-Signature— HMAC-SHA256 signature with replay-protection timestamp.Atlas-Webhook-Id— the webhook endpoint that this delivery is bound to.
Verifying the signature
Always verify the signature before trusting a payload. The signing scheme is HMAC-SHA256("<timestamp>.<raw_body>", webhook_secret). The timestamp prefix is what gives you replay protection — reject events whose timestamp is more than 5 minutes from your server clock.
import crypto from "crypto";
export function verifyAtlasSignature(
rawBody: Buffer,
header: string,
secret: string,
toleranceSeconds = 300,
): boolean {
// Header format: "t=<unix-ts>,v1=<hex-hmac>"
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").map((s) => s.trim())),
);
const t = parseInt(parts.t, 10);
const v1 = parts.v1;
if (!t || !v1) return false;
// Reject events older than the tolerance window (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > toleranceSeconds) return false;
// signed_payload = "<timestamp>.<raw_body>"
const signedPayload = `${t}.${rawBody.toString("utf8")}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// Constant-time compare to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(v1, "hex"),
Buffer.from(expected, "hex"),
);
}import hmac, hashlib, time
def verify_atlas_signature(
raw_body: bytes,
header: str,
secret: str,
tolerance: int = 300,
) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(int(time.time()) - t) > tolerance:
return False
signed_payload = f"{t}.{raw_body.decode('utf-8')}".encode()
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)require "openssl"
require "time"
def verify_atlas_signature(raw_body, header, secret, tolerance = 300)
parts = header.split(",").map { |p| p.split("=", 2) }.to_h
t = parts["t"]&.to_i
v1 = parts["v1"]
return false unless t && v1
return false if (Time.now.to_i - t).abs > tolerance
signed_payload = "#{t}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
OpenSSL.fixed_length_secure_compare(v1, expected)
endDelivery & retries
Atlas considers a delivery successful when your endpoint returns any 2xx within 10 seconds. Anything else — non-2xx, timeout, TLS error — is retried with exponential backoff: 10s, 1m, 5m, 15m, 1h, 6h, 24h. After 7 failed attempts the delivery is marked dead-letter and surfaced in the dashboard for manual replay.
- Always return 2xx as fast as possible. Queue downstream work and process it asynchronously — don't block the response.
- Dedupe on Atlas-Event-Id. Webhooks may be delivered more than once. Treat each event as at-least-once.
- Retries reuse the original Atlas-Event-Id. But the Atlas-Delivery counter increments. Both are visible in the dashboard.
- Webhook secrets are rotated from the dashboard. Old secrets keep verifying for 24 hours after rotation.
Event types
transaction.approvedTransaction reached APPROVED state. Fires for sales, refunds, voids, and pre-auths.
transaction.declinedTransaction reached DECLINED state. Payload includes the issuer responseCode.
transaction.cancelledTransaction was cancelled by the cardholder, the POS, or a timeout.
transaction.settledTransaction was successfully settled with the acquirer at end-of-day.
transaction.failed_to_settleSettlement attempt failed. Manual reconciliation required.
refund.completedA refund cleared and funds were returned to the cardholder.
dispute.openedAn issuer-initiated chargeback or retrieval was received.
dispute.evidence_requiredThe acquirer is awaiting evidence. Includes the response deadline.
dispute.closedChargeback resolved — see data.outcome (won, lost, withdrawn).
preauth.expiredA pre-auth aged out without being captured. Funds were released.
reconciliation.completedEnd-of-day reconciliation closed successfully.
reconciliation.failedEnd-of-day reconciliation failed. Manual intervention needed.
terminal.onlineA terminal that was offline came back online.
terminal.offlineA terminal stopped responding to heartbeats for >60 seconds.
terminal.firmware_updatedTerminal firmware was upgraded. Includes old + new version.
Example payload
Every event shares the same envelope: id, type, createdAt, livemode, and data. The shape of data depends on the event type.
{
"id": "evt_01JQXYZW0001",
"type": "transaction.settled",
"createdAt": "2026-04-08T23:00:00Z",
"livemode": true,
"data": {
"transactionId": "txn_01JQXYZ123456",
"status": "APPROVED",
"type": "SALE",
"amount": 2500,
"currency": "NZD",
"settlementBatchId": "stl_01JQDAY00042",
"settledAt": "2026-04-08T23:00:00Z",
"merchantId": "MID123456789",
"terminalId": "TID-00012345"
}
}Local development
Use the Atlas CLI to forward sandbox webhook events to your local machine during development. Install the CLI with npm, then start listening:
# Install the CLI
npm install -g @atlas-softpos/cli
# Authenticate (one-time)
atlas auth login
# Forward sandbox events to your local server
atlas listen --forward-to http://localhost:4242/atlas-webhook
# The CLI prints a temporary webhook URL that you can register
# in the dashboard, or it auto-registers for your sandbox.The CLI prints each incoming event with its type, delivery ID, and your server's response status — making it easy to debug signature verification and event handling in real time.