Zapier

Connect Railbed to thousands of apps with Zapier, using Zapier's own Webhooks and Code steps. Receive signed payment events, read payments, orders and customers, mark orders fulfilled and send checkout links, with ready-made recipes.

On this page

What you need

These steps use apps Zapier already has, so they work with no Railbed app in Zapier:

  • Webhooks by Zapier, to receive Railbed's events and to call the Railbed API. It's one of Zapier's premium apps, so check that your Zapier plan includes it.
  • Code by Zapier, to check each event's signature and turn it into simple fields. On Zapier's Free plan a Code step may run for one second; paid plans allow longer.
  • A Railbed secret key or a webhook endpoint, both made in Developers.

Build and test everything in Test mode first (turn it on in the business block at the top of the sidebar). Test payments move no money, and Test and Live each have their own keys, endpoints and secrets.

Receive events in a Zap

Railbed sends a signed event when a payment starts, is paid, needs your review, or closes unpaid. A Zap receives it with Catch Raw Hook, and a Code step checks that Railbed sent it before anything else runs.

  1. In Zapier, create a Zap with the trigger Webhooks by Zapier → Catch Raw Hook, and copy its webhook URL. Use Catch Raw Hook, not Catch Hook: the signature covers the exact body Railbed sent, and only Catch Raw Hook keeps that body and the request's headers.
  2. In Railbed, open Developers → Add endpoint. Paste the Zapier URL, add a description such as "Zapier: paid orders to Sheets", choose the events this Zap needs (often just payment.paid) and save. Copy the endpoint's signing secret (whsec_…).
  3. On the endpoint, choose Send test event and pick payment.paid. Back in Zapier, test the trigger: the request appears with a Raw Body and its headers.
  4. Add an action step Code by Zapier → Run JavaScript. Under Input Data, add these names and values, then paste the code below into Code and test the step:
Input Data nameValue
raw_bodyThe trigger's Raw Body
signatureThe trigger's Railbed-Signature header, which Zapier lists as Headers Http Railbed Signature
secretThe endpoint's signing secret, pasted as text
max_age_secondsOptional. How old a delivery may be, in seconds; 3600 when empty
Code by Zapier: check the signature and read the event
// Railbed → Zapier: check a webhook's signature, then turn the event into simple fields.
//
// Paste this whole file into a "Code by Zapier → Run JavaScript" step that follows
// "Webhooks by Zapier → Catch Raw Hook". Fill in the step's Input Data:
//
//   raw_body         Catch Raw Hook's "Raw Body"
//   signature        Catch Raw Hook's Railbed-Signature header ("Headers Http Railbed Signature")
//   secret           the endpoint's signing secret from Railbed (whsec_…)
//   max_age_seconds  optional; how old a delivery may be, in seconds (default 3600)
//
// A delivery whose signature doesn't match stops the Zap with an error, so nothing after
// this step runs for it. Guide: https://railbed.com/docs/guides/zapier/

const crypto = require('crypto');

const rawBody = typeof inputData.raw_body === 'string' ? inputData.raw_body : '';
const header = String(inputData.signature || '').trim();
const secret = String(inputData.secret || '').trim();
const maxAge = inputData.max_age_seconds ? Number(inputData.max_age_seconds) : 3600;

if (!/^whsec_[A-Za-z0-9_-]+$/.test(secret)) {
  throw new Error('Add the endpoint\'s signing secret (it starts with whsec_) to this step\'s "secret" input.');
}
if (!Number.isFinite(maxAge) || maxAge <= 0) {
  throw new Error('max_age_seconds must be a number of seconds, such as 3600.');
}

// Railbed-Signature: t=<Unix seconds>,v1=<hex HMAC>[,v1=<hex HMAC>] (two v1 values for 24 hours after a secret roll).
const match = /^t=(\d{1,12})((?:,v1=[0-9a-f]{64})+)$/.exec(header);
if (!match) {
  throw new Error('This request has no valid Railbed-Signature header, so it was not sent by Railbed. Nothing after this step ran.');
}
const timestamp = match[1];
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > maxAge) {
  throw new Error(`This delivery was signed ${Math.round(age)} seconds ago, longer than max_age_seconds (${maxAge}). Check the payment in Railbed, or resend the delivery from Developers.`);
}
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex'));
const genuine = match[2].slice(1).split(',').some((part) => {
  const given = Buffer.from(part.slice(3));
  return given.length === expected.length && crypto.timingSafeEqual(expected, given);
});
if (!genuine) {
  throw new Error('The Railbed-Signature doesn\'t match this body and secret. Check that the secret belongs to this endpoint and mode (Test and Live differ), and that "raw_body" is mapped from Catch Raw Hook. Nothing after this step ran.');
}

const event = JSON.parse(rawBody);
const data = event.data || {};
const payment = data.payment || null;
const order = data.order || null;
const customer = data.customer || null;
const iso = (ms) => (typeof ms === 'number' ? new Date(ms).toISOString() : null);
const address = (prefix, a) => ({
  [`${prefix}_name`]: a ? a.name : null,
  [`${prefix}_line1`]: a ? a.line1 : null,
  [`${prefix}_line2`]: a ? a.line2 : null,
  [`${prefix}_city`]: a ? a.city : null,
  [`${prefix}_region`]: a ? a.region : null,
  [`${prefix}_postal_code`]: a ? a.postalCode : null,
  [`${prefix}_country`]: a ? a.country : null,
});

return {
  verified: true,
  event_id: event.id,
  event_type: event.type,
  event_created_at: typeof event.created === 'number' ? new Date(event.created * 1000).toISOString() : null,
  mode: event.livemode ? 'live' : 'test',
  // Send test event in Developers uses a made-up payment that the API doesn't know.
  is_sample: !!(payment && payment.metadata && payment.metadata.sample === 'true'),

  payment_id: payment ? payment.id : null,
  payment_status: payment ? payment.status : null,
  amount: payment ? payment.amount : null,
  currency: payment ? payment.currency : null,
  description: payment ? payment.description : null,
  reference: payment ? payment.reference : null,
  customer_email: payment ? payment.customerEmail : null,
  provider: payment ? payment.provider : null,
  provider_name: payment ? payment.providerName : null,
  method: payment ? payment.method : null,
  hold_reason: payment ? payment.holdReason : null,
  hold_acceptable: payment ? !!payment.holdAcceptable : null,
  net_received: payment ? payment.merchantReceived : null,
  checkout_url: payment ? payment.url : null,
  created_at: payment ? iso(payment.createdAt) : null,
  paid_at: payment ? iso(payment.paidAt) : null,
  metadata: payment && payment.metadata ? payment.metadata : {},

  order_id: order ? order.id : null,
  order_number: order ? order.displayNumber : null,
  order_status: order ? order.status : null,
  order_needs_review: order ? !!order.needsReview : null,
  order_fulfillment: order ? order.fulfillment : null,
  order_summary: order ? order.summary : null,
  order_subtotal: order ? order.subtotal : null,
  order_discount: order ? order.discount : null,
  order_shipping: order ? order.shipping : null,
  order_tax: order ? order.tax : null,
  order_total: order ? order.total : null,
  order_store_order_id: order && order.store ? order.store.orderId : null,
  order_items: order && Array.isArray(order.items)
    ? order.items.map((item) => ({
        name: item.name,
        variant: item.variant,
        sku: item.sku,
        quantity: item.quantity,
        unit_amount: item.unitAmount,
        amount: item.amount,
      }))
    : [],
  ...address('shipping', order ? order.shipTo : null),

  customer_id: customer ? customer.id : null,
  customer_name: customer ? customer.name : null,
  customer_phone: customer ? customer.phone : null,
};

Then add the steps that do the work, mapping the Code step's fields below. If the endpoint receives several event types, add a Filter by Zapier step first that continues only when Event Type is the one this Zap handles.

A request that isn't from Railbed, or that was changed on the way, stops the Zap with an error at the Code step, so nothing after it runs. Zapier tells you about errors, so a wrong secret shows up on the first event. The Code step allows a delivery up to an hour old rather than the usual five minutes, because Zapier can run a step some minutes after the request arrived; the signature must still match.

FieldWhat it is
event_id, event_typeThe event's id (evt_…) and type, such as payment.paid
modelive or test
is_sampletrue for the made-up payment that Send test event sends. It isn't in your account, so skip any step that looks it up
payment_id, payment_statusThe payment (pay_…) and its status, such as paid or held
amount, currencyThe price you set, such as 49.00 and USD
description, reference, metadataWhat the buyer paid for, your reference and your metadata
customer_emailThe email the buyer typed. Fine for a receipt, but don't use it to decide whose account to credit
provider, provider_name, methodHow the buyer paid
hold_reason, hold_acceptableFor a payment that needs your review: why, and whether you can accept it as paid
net_receivedWhat reached your payout wallet after fees, in dollars, once reported
paid_at, created_at, checkout_urlWhen it was paid and created, and its checkout page
order_id, order_number, order_status, order_fulfillment, order_summary, order_totalThe order the payment belongs to
order_itemsThe order's lines, as Zapier line items: name, variant, sku, quantity, unit_amount, amount
shipping_name … shipping_countryWhere the order ships
customer_id, customer_name, customer_phoneThe customer

Fulfil only on payment.paid. Events can arrive more than once and Zapier doesn't drop repeats, so when a step must happen once (a shipment, a licence key), check first in the destination for the payment_id, for example with a Lookup Spreadsheet Row step. Turning the Zap off doesn't remove the endpoint: remove it in Developers, since each mode allows 10.

Call the API from a Zap

Use Webhooks by Zapier → Custom Request for every call. Its settings:

SettingValue
MethodAs in each request below
URLAs below, with the ids mapped from earlier steps
DataThe JSON body, for requests that send one; empty otherwise
HeadersAuthorization: Bearer followed by a space and your secret key. Add Content-Type: application/json when you send data

The key is saved in the step, where anyone who can edit the Zap can read it. Use a Read only key for steps that only read, and a Full access key only in steps that create sessions or update orders. Zapier's API by Zapier app (in beta, on paid plans) keeps credentials in a connection instead of the step; if your plan has it, its API Request action takes the same method, URL and body. Every response is JSON, which Zapier turns into fields for later steps; an error has an error.code and an error.message (Errors).

Create a checkout session and send the link

POST https://pay.railbed.io/v1/checkout_sessions. Add the header Idempotency-Key and map something unique to this order into it, such as the form entry's id: if Zapier runs the step again, you get the same session back instead of a second one.

Data
{
  "amount": "49.00",
  "currency": "USD",
  "description": "Pro Membership",
  "customer_email": "buyer@example.com",
  "reference": "order_1042",
  "success_url": "https://yourstore.com/thanks"
}

Map your own values in place of the examples: amount is text with two decimal places, from 1.00 to 100000.00, and currency is USD, EUR, GBP, CAD or AUD. The response's url is the checkout page to send the buyer, and id is the payment to look up later. Every field is in Create a checkout session.

Read payments

RequestWhat it gives you
GET https://pay.railbed.io/v1/payments/pay_…One payment, as it is now
GET https://pay.railbed.io/v1/payments?status=paid&limit=20The newest paid payments, newest first, in data
GET https://pay.railbed.io/v1/payments?reference=order_1042Payments with your reference

Put the payment's id at the end of the first URL. The API's names are snake_case and its times are Unix seconds (paid_at), unlike the event's fields; see List payments for every filter.

Read orders and customers

RequestWhat it gives you
GET https://pay.railbed.io/v1/orders/ord_…One order, with every item and address
GET https://pay.railbed.io/v1/orders?limit=20The newest orders
GET https://pay.railbed.io/v1/orders?store_order_id=1042The order your system sent with order.id 1042
GET https://pay.railbed.io/v1/customers/cus_…One customer, with order counts and what they spent per currency
GET https://pay.railbed.io/v1/customers?email=buyer@example.comThe customer with that email

Mark an order fulfilled

PATCH https://pay.railbed.io/v1/orders/ord_… with this Data. Use unfulfilled to undo it. The order's timeline in Railbed records the change; see Update an order.

Data
{ "fulfillment": "fulfilled" }

Record a cancellation or refund

POST https://pay.railbed.io/v1/orders/ord_…/events. This only records on the order what your store did; Railbed can't send money back to a buyer. id is your own id for the event, so a repeated step records it once. See Report a cancellation or refund.

Data
{ "type": "refunded", "id": "refund_5521", "amount": "12.00" }

Simulate a payment in Test mode

With a rb_test_… key, create a session, then POST https://pay.railbed.io/v1/checkout_sessions/pay_…/start with Data { "customer_email": "buyer@example.com" }, then POST https://pay.railbed.io/v1/payments/pay_…/simulate with Data { "outcome": "paid" }. Your Test endpoint receives the real sequence of events for that payment. Other outcomes are in Simulate a payment.

Recipes

Each recipe starts with the receiving steps (Catch Raw Hook, then the Code step) unless it says otherwise. Choose only the events the recipe needs on its endpoint.

WhenThenEvents and steps
A payment is paidAdd a row to a sheetpayment.paid → Google Sheets: Create Spreadsheet Row with paid_at, order_number, customer_email, amount, currency, net_received and payment_id
A payment is paidTell the teampayment.paid → Slack: Send Channel Message, such as "Paid: order_summary, amount currency, from customer_email"
A payment is paidEmail the buyer a receiptpayment.paid → Gmail: Send Email to customer_email, with order_items, order_total and your reference
A payment is paidShip it, then mark it fulfilledpayment.paid → your shipping or fulfilment app → a Custom Request that marks the order fulfilled with order_id
A payment needs your reviewGet an alertpayment.held → Slack or SMS by Zapier, with hold_reason and checkout_url. Don't fulfil: decide in Payments (held payments)
A buyer pays for the first timeAdd them to your CRM or listpayment.paid → HubSpot: Create or Update Contact, or Mailchimp: Add/Update Subscriber, with customer_email and customer_name
A form is sent or a deal is wonSend a checkout linkYour form or CRM trigger (no receiving steps) → a Custom Request that creates a session, with the form entry's id as Idempotency-Key → Gmail: Send Email with the response's url
Every morningPost yesterday's salesSchedule by Zapier: Every Day (no receiving steps) → the Code step below → Slack or Gmail with sales_summary, paid_count and net_received

The daily summary

This Code step reads your paid payments from the API and adds them up for the last day. Give it a Read only secret key as secret_key under Input Data; optionally hours (default 24) and lookback_days (default 2), how far back to look for payments created earlier and paid in the period. Totals stay per currency, such as 49.30 USD, 12.50 EUR; net_received is what reached your payout wallet, in dollars, and net_received_pending counts paid payments whose amount isn't reported yet. It reads up to 500 payments; complete is false when there were more.

Code by Zapier: yesterday's sales
// Railbed → Zapier: add up the payments that became paid in the last day.
//
// Paste this whole file into a "Code by Zapier → Run JavaScript" step that follows
// "Schedule by Zapier → Every Day". Fill in the step's Input Data:
//
//   secret_key     a Railbed secret key; a Read only key is enough (rb_live_… or rb_test_…)
//   hours          optional; the period to add up, in hours (default 24)
//   lookback_days  optional; how far back to look for payments created earlier and paid in the
//                  period (default 2: checkout sessions stay open 24 hours; raise it if you use
//                  payment links that stay open longer)
//
// Totals are kept per currency and never converted or added across currencies.
// Guide: https://railbed.com/docs/guides/zapier/

const API = 'https://pay.railbed.io/v1';
const MAX_PAGES = 5; // 500 payments; Code steps have a short time limit

const key = String(inputData.secret_key || '').trim();
const hours = inputData.hours ? Number(inputData.hours) : 24;
const lookbackDays = inputData.lookback_days ? Number(inputData.lookback_days) : 2;
if (!/^rb_(live|test)_[A-Za-z0-9_-]+$/.test(key)) {
  throw new Error('Add a Railbed secret key (it starts with rb_live_ or rb_test_) to this step\'s "secret_key" input.');
}
if (!(hours > 0) || !(lookbackDays > 0)) {
  throw new Error('hours and lookback_days must be positive numbers.');
}

const now = Math.floor(Date.now() / 1000);
const periodStart = now - Math.round(hours * 3600);
const createdSince = Math.min(periodStart, now - Math.round(lookbackDays * 86400));

// Decimal strings → whole units of the smallest digit, so nothing is rounded on the way.
const toUnits = (value, places) => {
  const [whole, fraction = ''] = String(value).split('.');
  return BigInt(whole + fraction.padEnd(places, '0').slice(0, places));
};
const fromUnits = (units, places) => {
  const text = units.toString().padStart(places + 1, '0');
  return `${text.slice(0, -places)}.${text.slice(-places)}`;
};

const sales = {}; // currency → cents
let paidCount = 0;
let netMicros = 0n; // dollars received after fees, six decimal places
let netMissing = 0;
let cursor = null;
let pages = 0;
let more = true;
while (more && pages < MAX_PAGES) {
  const params = new URLSearchParams({ status: 'paid', limit: '100', created_gte: String(createdSince) });
  if (cursor) params.set('starting_after', cursor);
  const response = await fetch(`${API}/payments?${params}`, { headers: { Authorization: `Bearer ${key}` } });
  const body = await response.json().catch(() => null);
  if (!response.ok) {
    const reason = body && body.error ? `${body.error.message} (${body.error.code})` : `HTTP ${response.status}`;
    throw new Error(`Railbed didn't return the payments: ${reason}`);
  }
  for (const payment of body.data) {
    if (!payment.paid_at || payment.paid_at < periodStart) continue;
    paidCount += 1;
    sales[payment.currency] = (sales[payment.currency] || 0n) + toUnits(payment.amount, 2);
    const net = payment.settlement && payment.settlement.merchant_received;
    if (net) netMicros += toUnits(net, 6);
    else netMissing += 1;
  }
  pages += 1;
  more = body.has_more;
  cursor = body.next_cursor;
}

const currencies = Object.keys(sales).sort();
const summary = {
  period_start: new Date(periodStart * 1000).toISOString(),
  period_end: new Date(now * 1000).toISOString(),
  paid_count: paidCount,
  sales_summary: currencies.length ? currencies.map((c) => `${fromUnits(sales[c], 2)} ${c}`).join(', ') : 'No paid payments',
  net_received: fromUnits(netMicros, 6),
  net_received_pending: netMissing,
  complete: !more,
};
for (const c of currencies) summary[`sales_${c.toLowerCase()}`] = fromUnits(sales[c], 2);
return summary;

Troubleshooting

SymptomLikely cause
The Code step says the signature doesn't matchThe secret is from another endpoint or mode, or raw_body isn't mapped from Raw Body. Each endpoint has its own secret, and Test and Live differ
The Code step says there's no valid signatureThe trigger is Catch Hook rather than Catch Raw Hook, or signature isn't mapped
The delivery was signed too long agoZapier ran the step more than an hour after the event: check the payment in Railbed, or resend the delivery from Developers
A Custom Request returns 401The key was revoked, or the Authorization header doesn't read Bearer and the key
A Custom Request returns 403 insufficient_scopeThe key is Read only and the request changes something: use a Full access key in that step
A lookup returns 404 for a Test eventThe event came from Send test event; its payment isn't real (is_sample is true)
Adding the endpoint failsEach mode allows 10 endpoints: remove ones you no longer use in Developers

Updated · This page as Markdown