RMO Developers

Webhooks

Real-time notifications for authorization events. Signed with HMAC-SHA256 + timestamped so you can reject replays. Retried with exponential backoff. Idempotent by delivery ID.

How delivery works

When something happens on your tenant (an authorization is approved, captured, etc.), we enqueue a delivery to every subscription that’s active and listening for that event type. A background worker picks them up and POSTs to your URL with:

POST https://your-app.example.com/webhooks/rmo
Content-Type:      application/json
X-RMO-Signature:   sha256=<hex digest>
X-RMO-Timestamp:   <unix seconds>
X-RMO-Event:       authorization.approved
X-RMO-Delivery:    <17-char delivery RecordId>
Idempotency-Key:   <event-type>:<source idempotency-key>
User-Agent:        rmo-network-webhooks/1.0

{
  "id":           "7421589",
  "recordId":     "A1b2C3d4E5f6G7h8I",
  "amount":       42.50,
  "currency":     "USD",
  "approvalCode": "A7B2C3",
  "card":         "<card record id>",
  "merchant":     "<merchant record id>",
  "channel":      "POS"
}
Always verify the signature before trusting the body. An unsigned HTTPS POST to a public endpoint is exactly what an attacker would do to forge events. The signature check below is the only thing standing between you and a forged authorization.captured notification.

Signature scheme

The signature is HMAC-SHA256 over timestamp + "." + body, using your subscription’s signing secret (the whsec_… returned at create / rotate time) as the HMAC key.

Don’t confuse the two secrets. Your sk_* bearer authenticates your requests to RMO. The whsec_* subscription secret authenticates RMO’s deliveries to you. They’re different keys.

Verify in Python (Flask)

import hmac, hashlib, time, os
from flask import Flask, request, abort

SUBSCRIPTION_SECRET = os.environ["RMO_WEBHOOK_SECRET"].encode()
MAX_DRIFT_SECONDS   = 300
app = Flask(__name__)

@app.post("/webhooks/rmo")
def rmo_webhook():
    ts   = request.headers.get("X-RMO-Timestamp", "")
    sig  = request.headers.get("X-RMO-Signature", "")
    body = request.get_data()

    # 1. Reject if missing
    if not ts or not sig.startswith("sha256="):
        abort(400)

    # 2. Reject if drift > 5 minutes (anti-replay)
    if abs(time.time() - int(ts)) > MAX_DRIFT_SECONDS:
        abort(400)

    # 3. Recompute and compare in constant time
    payload   = ts.encode() + b"." + body
    expected  = hmac.new(SUBSCRIPTION_SECRET, payload, hashlib.sha256).hexdigest()
    provided  = sig.split("=", 1)[1]
    if not hmac.compare_digest(expected, provided):
        abort(401)

    # 4. Idempotency: skip if X-RMO-Delivery already seen
    delivery_id = request.headers["X-RMO-Delivery"]
    if already_processed(delivery_id):
        return "", 200

    # 5. Process
    event = request.headers["X-RMO-Event"]
    data  = request.get_json()
    handle_event(event, data)
    mark_processed(delivery_id)
    return "", 200

Verify in Node.js (Express)

const express = require("express");
const crypto  = require("crypto");
const app = express();
const SECRET = process.env.RMO_WEBHOOK_SECRET;
const MAX_DRIFT = 300;

// IMPORTANT: capture raw body for signing
app.use("/webhooks/rmo", express.raw({ type: "application/json" }));

app.post("/webhooks/rmo", (req, res) => {
  const ts  = req.get("X-RMO-Timestamp") || "";
  const sig = req.get("X-RMO-Signature") || "";
  if (!ts || !sig.startsWith("sha256=")) return res.sendStatus(400);

  if (Math.abs(Date.now()/1000 - parseInt(ts)) > MAX_DRIFT) {
    return res.sendStatus(400);
  }

  const payload  = Buffer.concat([Buffer.from(ts + "."), req.body]);
  const expected = crypto.createHmac("sha256", SECRET).update(payload).digest("hex");
  const provided = sig.slice(7);
  const ok = expected.length === provided.length
    && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
  if (!ok) return res.sendStatus(401);

  const deliveryId = req.get("X-RMO-Delivery");
  if (alreadyProcessed(deliveryId)) return res.sendStatus(200);

  handleEvent(req.get("X-RMO-Event"), JSON.parse(req.body));
  markProcessed(deliveryId);
  res.sendStatus(200);
});

Event types

Subscribe to one or more of:

EventFires when
authorization.approvedA new card auth or payment-code redemption is approved.
authorization.declinedAn auth is declined for any reason — limit, expired card, wrong PIN, etc.
authorization.capturedA capture (partial or full) is applied to an approved auth.
authorization.reversedA reverse / refund (partial or full) is applied.

Retry policy

If your endpoint returns a non-2xx or times out (10s connect+read), we retry on this schedule:

AttemptDelay
1 (initial)immediate
2+1 minute
3+5 minutes
4+30 minutes
5+2 hours
6+12 hours
7 (last)+24 hours

After 7 attempts the delivery is marked Webhook Retry Exhausted. After 50 consecutive failures on a subscription, the subscription itself is auto-paused — you’ll need to investigate and reactivate it via PATCH /v1/webhook-subscriptions/<id> {"status":"active"}.

Best practices

Where to next

Manage subscriptions → Error reference → Quickstart →