kepa
GETTING STARTED

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-Idglobally unique event identifier — use it to dedupe.
  • Atlas-Event-Typethe event type, e.g. transaction.settled.
  • Atlas-Deliverydelivery attempt counter, starting at 1.
  • Atlas-SignatureHMAC-SHA256 signature with replay-protection timestamp.
  • Atlas-Webhook-Idthe 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)
end

Delivery & 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.approved

Transaction reached APPROVED state. Fires for sales, refunds, voids, and pre-auths.

transaction.declined

Transaction reached DECLINED state. Payload includes the issuer responseCode.

transaction.cancelled

Transaction was cancelled by the cardholder, the POS, or a timeout.

transaction.settled

Transaction was successfully settled with the acquirer at end-of-day.

transaction.failed_to_settle

Settlement attempt failed. Manual reconciliation required.

refund.completed

A refund cleared and funds were returned to the cardholder.

dispute.opened

An issuer-initiated chargeback or retrieval was received.

dispute.evidence_required

The acquirer is awaiting evidence. Includes the response deadline.

dispute.closed

Chargeback resolved — see data.outcome (won, lost, withdrawn).

preauth.expired

A pre-auth aged out without being captured. Funds were released.

reconciliation.completed

End-of-day reconciliation closed successfully.

reconciliation.failed

End-of-day reconciliation failed. Manual intervention needed.

terminal.online

A terminal that was offline came back online.

terminal.offline

A terminal stopped responding to heartbeats for >60 seconds.

terminal.firmware_updated

Terminal 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.