Skip to content

Documentation / Billing

Send approved quotes to your billing

When a quote is approved, Atlas creates the matching record in your own billing system: a customer and an invoice in Stripe, a quote in Chargebee, or a signed event for anything else. Your processor charges the buyer, exactly as it does today.

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:

ConnectorWhat Atlas createsWho collects payment
StripeThe customer (if new) and an invoice, as a draft or finalizedYour Stripe account, with your invoice settings
ChargebeeThe customer (if new) and a quote, or an invoice with auto-collection offYour Chargebee site
Signed webhookA signed quote.approved event to your endpointWhatever 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

  1. In Stripe, create a restricted key with: Customers write, Invoices write, Products and Prices read. No payment permissions are needed.
  2. 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.
  3. 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

  1. In Chargebee, create a full-access or custom API key for your site (acme or acme-test).
  2. 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

TypeWhen
quote.approvedAn 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.requestedA quote is above your threshold and waits for a person.
approval.decidedA person approved or rejected it (data.approval.decision).
quote.revokedYour team revoked an open quote.
quote.expiredA quote passed valid_until without being executed.
webhook.testYou pressed Send test event. Always delivered, carries no quote.

Headers

HeaderValue
PairRail-Signaturet=<unix seconds>,v1=<hex>, with a second v1 during a secret rotation
PairRail-Event-IdThe event id, the same on every retry and resend
PairRail-Event-TypeFor example quote.approved
Idempotency-KeySame 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 False

There is no PairRail SDK package yet; these functions are the whole verification step.

Retries, ordering and status

  • Success is any 2xx within 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 Gone disables 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.