Overview
An agent asks for a quote, your published rules price it, and a person approves it when it is above your threshold. On execute, Atlas hands the approved quote to the one billing connector you choose in Workspace → Integrations:
| Connector | What Atlas creates | Who collects payment |
|---|---|---|
| Stripe | The customer (if new) and an invoice, as a draft or finalized | Your Stripe account, with your invoice settings |
| Chargebee | The customer (if new) and a quote, or an invoice with auto-collection off | Your Chargebee site |
| Signed webhook | A signed quote.approved event to your endpoint | Whatever your receiver calls |
Every handoff is idempotent: retrying an execute never creates a second invoice or quote. Connector keys are encrypted per seller and never shown again after you save them.
Stripe
- In Stripe, create a restricted key with: Customers write, Invoices write, Products and Prices read. No payment permissions are needed.
- In Workspace → Integrations → Stripe, paste the key and choose what each approved quote becomes: a draft invoice you review (default) or a finalized invoice ready to send. Atlas tests the key before saving it.
- Choose Use for handoff if Stripe is not already your handoff.
Atlas finds the buyer’s customer by metadata.pairrail_buyer_key, or creates it, then creates the invoice with collection_method=send_invoice and one invoice item per quote line (plus a negative line for an approved discount). The invoice carries metadata.pairrail_quote_id and the catalog version, so your team can trace it. The same key imports Stripe products into a catalog draft from Governance → Connect stack.
Chargebee
- In Chargebee, create a full-access or custom API key for your site (
acmeoracme-test). - In Workspace → Integrations → Chargebee, enter the site and key and choose quote (default) or invoice.
Atlas creates the customer with id pr_… (a stable hash of the buyer) if it does not exist, then a quote built from one-off charges, one per quote line, with po_number set to the PairRail quote id. If your site does not have Quotes, Atlas creates an invoice with auto_collection=off instead, so nothing is charged until your team or your Chargebee rules say so. The same key imports Chargebee items into a catalog draft.
Signed webhook
For any other billing system, Atlas POSTs signed JSON to one https endpoint you register in Workspace → Integrations → Signed webhook. You choose which events to receive, the signing secret is shown once, and Send test event delivers a webhook.test event right away. The delivery log shows every attempt with its status code, latency and the start of your response, and lets you resend any event.
Events
| Type | When |
|---|---|
quote.approved | An approved quote is executed. With the webhook as your billing handoff, this event is the handoff (data.handoff: true). With Stripe or Chargebee, it reports the handoff and its reference. |
approval.requested | A quote is above your threshold and waits for a person. |
approval.decided | A person approved or rejected it (data.approval.decision). |
quote.revoked | Your team revoked an open quote. |
quote.expired | A quote passed valid_until without being executed. |
webhook.test | You pressed Send test event. Always delivered, carries no quote. |
Headers
| Header | Value |
|---|---|
PairRail-Signature | t=<unix seconds>,v1=<hex>, with a second v1 during a secret rotation |
PairRail-Event-Id | The event id, the same on every retry and resend |
PairRail-Event-Type | For example quote.approved |
Idempotency-Key | Same as idempotency_key in the body |
Payload (version 2026-10-01)
{
"version": "2026-10-01",
"id": "evt_8c1d2f…",
"type": "quote.approved",
"created_at": "2026-10-01T12:00:00.000Z",
"seller_id": "northstar-compute",
"catalog_version": "catv_42",
"idempotency_key": "pairrail_qr_7f3a_quote.approved",
"data": {
"quote": {
"id": "qr_7f3a",
"offer_id": "off_gpu_a100",
"status": "approved",
"currency": "USD",
"line_items": [
{ "description": "A100 80GB", "quantity": 500, "periods": 1,
"unit_price": "1.89", "amount": "945.00" }
],
"subtotal": "945.00",
"discount": "94.50",
"total": "850.50",
"tax_treatment": "exclusive",
"valid_until": "2026-10-08T12:00:00.000Z",
"buyer_reference": "acme-procurement",
"policy_decision": { "type": "approved", "reasons": [], "required_approvers": [] },
"approver": { "id": "firebase:u_91…", "decided_at": "2026-10-01T11:58:02.000Z", "note": null },
"created_at": "2026-10-01T11:40:00.000Z"
},
"handoff": true
}
}Amounts are decimal strings in the quote currency. approver is null when the quote was within your auto-approval threshold. Events never include buyer payment tokens or mandates. Use idempotency_key to ignore duplicates: it is stable per quote and event type.
Signing & verification
Each request is signed with HMAC-SHA256 over timestamp + "." + raw body, keyed by your signing secret. Verify against the raw bytes you received, before parsing JSON, and reject timestamps more than 5 minutes from now. During a rotation the previous secret keeps signing for 24 hours, so accept either.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the exact bytes received (Buffer or string), before JSON.parse.
// secrets: your current signing secret, plus the previous one during a rotation.
export function verifyPairRail(rawBody, header, secrets, toleranceSeconds = 300) {
let timestamp = null;
const signatures = [];
for (const part of String(header || "").split(",")) {
const [key, value] = part.split("=");
if (key === "t") timestamp = Number(value);
if (key === "v1") signatures.push(value);
}
if (!timestamp || !signatures.length) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
return secrets.some((secret) => {
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return signatures.some((signature) => {
const received = Buffer.from(signature, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
});
});
}app.post("/webhooks/pairrail", express.raw({ type: "application/json" }), async (req, res) => {
const ok = verifyPairRail(req.body, req.get("PairRail-Signature"), [process.env.PAIRRAIL_WEBHOOK_SECRET]);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
if (await seen(event.idempotency_key)) return res.sendStatus(200);
if (event.type === "quote.approved") await createInvoiceInYourBilling(event.data.quote);
res.sendStatus(200);
});import hashlib
import hmac
import time
def verify_pairrail(raw_body: bytes, header: str, secrets: list, tolerance: int = 300) -> bool:
timestamp, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or not signatures or abs(time.time() - timestamp) > tolerance:
return False
signed = f"{timestamp}.".encode() + raw_body
for secret in secrets:
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, signature) for signature in signatures):
return True
return FalseThere is no PairRail SDK package yet; these functions are the whole verification step.
Retries, ordering and status
- Success is any
2xxwithin 10 seconds. Respond fast and do slow work afterwards. - Retries follow 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours (each with up to 20% jitter), for at most 24 hours from the event. After that the event is marked failed and stays in the log for a manual resend.
410 Gonedisables the endpoint: nothing more is sent until you re-enable it in the workspace.- Order: events for the same quote are delivered in order. A later event for a quote waits until the earlier one was delivered or failed for good.
- Resend sends the same event id and body with a fresh signature.
- Rotation: Rotate secret shows a new secret once. Both secrets sign for 24 hours, then only the new one.
Endpoints set up before October 2026 keep receiving the earlier field names and the X-PairRail-Signature header alongside the new format until you turn that off in the workspace.
Dodo, Razorpay, Adyen and in-house billing
These work via the signed webhook. Your receiver verifies the event and creates whatever your account uses: an invoice, a checkout or a payment link in Dodo, Razorpay or Adyen, or an order in your own system. For example, a Razorpay invoice from quote.approved:
const { quote } = event.data;
await fetch("https://api.razorpay.com/v1/invoices", {
method: "POST",
headers: { Authorization: `Basic ${btoa(`${KEY_ID}:${KEY_SECRET}`)}`, "Content-Type": "application/json" },
body: JSON.stringify({
type: "invoice",
customer: { name: quote.buyer_reference },
line_items: quote.line_items.map((line) => ({
name: line.description,
amount: Math.round(Number(line.amount) * 100),
currency: quote.currency,
quantity: 1
})),
notes: { pairrail_quote_id: quote.id }
})
});Request a connector
Orb, Metronome and Paddle are not built yet. Choose Request connector on their card in Workspace → Integrations, or email [email protected]. Requests tell us what to build next; the signed webhook covers you in the meantime.
FAQ
Does PairRail process payments?
No. Atlas creates the quote, invoice or event in your billing system, and your processor collects payment exactly as it does today. See the FAQ for the full answer.
Can I use more than one connector?
One connector receives the handoff. You can still keep a webhook endpoint for notifications (for example approval.requested) while Stripe or Chargebee receives approved quotes.
What happens to approved quotes if my endpoint is down?
The quote is still handed off: the event waits in the outbox and retries for up to 24 hours, in order per quote. Resend it from the delivery log at any time.