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.
- 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.
- 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_…). - 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. - 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 name | Value |
|---|---|
raw_body | The trigger's Raw Body |
signature | The trigger's Railbed-Signature header, which Zapier lists as Headers Http Railbed Signature |
secret | The endpoint's signing secret, pasted as text |
max_age_seconds | Optional. How old a delivery may be, in seconds; 3600 when empty |
// 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.
| Field | What it is |
|---|---|
event_id, event_type | The event's id (evt_…) and type, such as payment.paid |
mode | live or test |
is_sample | true 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_status | The payment (pay_…) and its status, such as paid or held |
amount, currency | The price you set, such as 49.00 and USD |
description, reference, metadata | What the buyer paid for, your reference and your metadata |
customer_email | The email the buyer typed. Fine for a receipt, but don't use it to decide whose account to credit |
provider, provider_name, method | How the buyer paid |
hold_reason, hold_acceptable | For a payment that needs your review: why, and whether you can accept it as paid |
net_received | What reached your payout wallet after fees, in dollars, once reported |
paid_at, created_at, checkout_url | When it was paid and created, and its checkout page |
order_id, order_number, order_status, order_fulfillment, order_summary, order_total | The order the payment belongs to |
order_items | The order's lines, as Zapier line items: name, variant, sku, quantity, unit_amount, amount |
shipping_name … shipping_country | Where the order ships |
customer_id, customer_name, customer_phone | The 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:
| Setting | Value |
|---|---|
| Method | As in each request below |
| URL | As below, with the ids mapped from earlier steps |
| Data | The JSON body, for requests that send one; empty otherwise |
| Headers | Authorization: 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.
{
"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
| Request | What 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=20 | The newest paid payments, newest first, in data |
GET https://pay.railbed.io/v1/payments?reference=order_1042 | Payments 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
| Request | What 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=20 | The newest orders |
GET https://pay.railbed.io/v1/orders?store_order_id=1042 | The 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.com | The 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.
{ "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.
{ "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.
| When | Then | Events and steps |
|---|---|---|
| A payment is paid | Add a row to a sheet | payment.paid → Google Sheets: Create Spreadsheet Row with paid_at, order_number, customer_email, amount, currency, net_received and payment_id |
| A payment is paid | Tell the team | payment.paid → Slack: Send Channel Message, such as "Paid: order_summary, amount currency, from customer_email" |
| A payment is paid | Email the buyer a receipt | payment.paid → Gmail: Send Email to customer_email, with order_items, order_total and your reference |
| A payment is paid | Ship it, then mark it fulfilled | payment.paid → your shipping or fulfilment app → a Custom Request that marks the order fulfilled with order_id |
| A payment needs your review | Get an alert | payment.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 time | Add them to your CRM or list | payment.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 won | Send a checkout link | Your 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 morning | Post yesterday's sales | Schedule 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.
// 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
| Symptom | Likely cause |
|---|---|
| The Code step says the signature doesn't match | The 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 signature | The trigger is Catch Hook rather than Catch Raw Hook, or signature isn't mapped |
| The delivery was signed too long ago | Zapier 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 401 | The key was revoked, or the Authorization header doesn't read Bearer and the key |
A Custom Request returns 403 insufficient_scope | The key is Read only and the request changes something: use a Full access key in that step |
A lookup returns 404 for a Test event | The event came from Send test event; its payment isn't real (is_sample is true) |
| Adding the endpoint fails | Each mode allows 10 endpoints: remove ones you no longer use in Developers |