Receive new sales by webhook and post them to Slack
In this quickstart you build a small Node.js server that receives the receipt.created webhook ReceiptRoller sends each time a new sale is recorded for a business account, verifies its signature, drops duplicates, and posts a one-line message to a Slack channel. You register the webhook through the API and check it with a test event and the delivery log.
What you need
- An app registered in the developer portal (Creating a new app)
- A business account on the Starter plan or above. The developer API is included from Starter up. There is no sandbox, so use a business account that has real sales or sales you enter for testing
- An access token issued for that business account. When an owner or admin of the business account approves your app, the token is bound to that account (Your first API call: from app registration to listing transactions). The webhook API does not require a particular scope, but the token must be bound to a business account
- Node.js 20 or later, and Express
- A Slack Incoming Webhook URL (turn on "Incoming Webhooks" in your Slack app settings and pick the channel to post to)
- A public HTTPS URL for the receiver.
localhostand private IP addresses are refused at registration, so when testing on your machine use a tunnel such as ngrok or Cloudflare Tunnel
Step 1: Choose a signing secret
You can supply the secret that signs deliveries when you register the webhook, or leave it out and have one generated. A secret you supply must be 16 to 64 characters. Here you create it first and use it in both the receiver and the registration.
openssl rand -hex 24
Use the 48-character output as {webhook_secret} below. If you leave the secret out, a 32-character one is generated and returned once, in the registration response. No endpoint returns it later.
Step 2: Write the receiver
mkdir rr-slack && cd rr-slack
npm init -y
npm install express
Create server.mjs:
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.RR_WEBHOOK_SECRET;
const SLACK_URL = process.env.SLACK_WEBHOOK_URL;
const PORT = process.env.PORT || 3000;
// X-RR-Delivery values already handled. Retries end about 71 minutes after the first attempt,
// so three hours is enough. In production use Redis or a database: this map is lost on restart.
const processed = new Map();
const KEEP_MS = 3 * 60 * 60 * 1000;
function remember(deliveryId) {
processed.set(deliveryId, Date.now());
for (const [id, at] of processed) {
if (Date.now() - at > KEEP_MS) processed.delete(id);
}
}
// X-RR-Signature is "sha256=" + lower-case hex HMAC-SHA256 of the raw request body
function hasValidSignature(rawBody, header) {
if (!header || !Buffer.isBuffer(rawBody)) return false;
const expected = "sha256=" +
crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function slackText(event) {
if (event.event === "test.ping") {
return "Test event received from ReceiptRoller";
}
const sale = event.data;
const amount = new Intl.NumberFormat("en-US", {
style: "currency",
currency: sale.currency || "JPY",
}).format(sale.total_amount);
return `New sale ${amount} (store ${sale.store_id} / receipt ${sale.receipt_number || "-"} / ${sale.payment_method || "-"})`;
}
function isVoidOrRefund(event) {
const status = (event.data?.status || "").toLowerCase();
return status === "voided" || status === "refunded";
}
const app = express();
// The signature covers the exact bytes received, so verify the body before parsing it as JSON
app.post("/rr-webhook", express.raw({ type: "application/json" }), async (req, res) => {
if (!hasValidSignature(req.body, req.get("X-RR-Signature"))) {
return res.status(401).end();
}
const deliveryId = req.get("X-RR-Delivery");
if (processed.has(deliveryId)) {
return res.status(200).end(); // a retry of an event already handled: acknowledge only
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.event === "receipt.created" && isVoidOrRefund(event)) {
remember(deliveryId);
return res.status(200).end();
}
try {
const slack = await fetch(SLACK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: slackText(event) }),
signal: AbortSignal.timeout(5000),
});
if (!slack.ok) throw new Error(`Slack returned ${slack.status}`);
} catch (err) {
console.error(deliveryId, err);
return res.status(502).end(); // anything but 2xx makes ReceiptRoller retry
}
remember(deliveryId);
res.status(200).end();
});
app.listen(PORT, () => console.log(`listening on ${PORT}`));
Start it:
RR_WEBHOOK_SECRET={webhook_secret} \
SLACK_WEBHOOK_URL={slack_incoming_webhook_url} \
node server.mjs
In another terminal open a tunnel. The HTTPS URL it prints, followed by /rr-webhook, is your {receiver_url} (for example https://xxxx.ngrok-free.app/rr-webhook).
ngrok http 3000
What the server relies on:
- Signature:
X-RR-Signatureissha256=followed by the lower-case hex HMAC-SHA256 of the raw request body (its UTF-8 bytes), keyed with the webhook's secret. No timestamp is part of what is signed - Duplicates:
X-RR-Deliveryis the event's id, the same value as the payload'sid. A retry of the same event carries the same value and the same body - Time: ReceiptRoller stops waiting after 10 seconds and counts the attempt as failed. The Slack call is cut off after 5 seconds
- Voids and refunds:
receipt.createdis not filtered by status. To post sales only, the server acknowledges events whosestatusisVoidedorRefundedwithout posting them
Step 3: Register the webhook
curl -X POST https://receiptroller.io/api/v1/webhooks \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"url": "{receiver_url}",
"eventTypes": ["receipt.created"],
"secret": "{webhook_secret}"
}'
The response is 201. Its id is the {webhook_id} used below. secret appears in this response only.
{
"id": "wh_xxxxxxxxxxxxxxxx",
"clientId": "{client_id}",
"organizationId": "{organizationId}",
"url": "{receiver_url}",
"eventTypes": ["receipt.created"],
"active": true,
"createdAt": "2026-10-26T03:00:00.0000000Z",
"lastDeliveredAt": null,
"secret": "{webhook_secret}"
}
A webhook belongs to your app and to the business account of the token used to register it, and only that account's events are delivered to it. To receive sales from several business accounts, register one with each account's token. You can also register from the developer portal.
Errors you are likely to see here
| Status | Message | What to do |
|---|---|---|
| 401 | (no body) | The token is missing, expired or invalid. Get a new one with your refresh token |
| 400 | organization_id not found in token. |
The token is not bound to a business account. Use a token from the business-account authorization flow |
| 400 | url must be a valid HTTPS address. |
Use an absolute URL starting with https:// |
| 400 | url must not point to localhost or private IP ranges. |
Use a URL reachable from the internet, such as a tunnel |
| 400 | event_types must contain at least one event. |
The field is eventTypes. Written as event_types it is not read, and you get this error |
| 400 | Unknown event types: ... |
Check the spelling of the event name (receipt.created) |
| 400 | secret must be 16 to 64 characters, or omitted to have one generated. |
Use 16 to 64 characters, or leave secret out |
If getting the token itself fails with invalid_grant and a description saying needs Starter or above, the business account's plan does not include the developer API (The developer portal is not shown while on the Free plan).
Step 4: Send a test event
curl -X POST https://receiptroller.io/api/v1/webhooks/test/{webhook_id} \
-H "Authorization: Bearer {access_token}"
ReceiptRoller sends one test.ping to your receiver and reports the result. When "Test event received from ReceiptRoller" appears in Slack, everything from signature check to forwarding works.
{
"webhookId": "wh_xxxxxxxxxxxxxxxx",
"httpStatus": 200,
"success": true,
"deliveredAt": "2026-10-26T03:01:00.0000000Z"
}
The body your receiver gets looks like this:
{
"id": "evt_1a2b3c4d5e6f",
"event": "test.ping",
"timestamp": "2026-10-26T03:01:00.0000000Z",
"organization_id": "{organizationId}",
"data": { "message": "This is a test ping from Receipt Roller." }
}
- The API answers
200either way. ReadsuccessandhttpStatusto see whether the delivery got through.httpStatus0means the connection itself failed (check the URL, the tunnel and the TLS certificate) httpStatus401means your receiver rejected the signature. Check thatRR_WEBHOOK_SECRETmatches thesecretyou registered- A test event is sent once whatever the webhook subscribes to and whether or not it is active. It is not retried and does not appear in the delivery log
404means the{webhook_id}is wrong or belongs to another app
Step 5: Try it with a real sale
Record one sale at a store of the business account (through a connected POS, a payment terminal or a CSV import). Your receiver gets a body like this, with the header X-RR-Event: receipt.created:
{
"id": "evt_9f8e7d6c5b4a",
"event": "receipt.created",
"timestamp": "2026-10-26T03:15:43.1234567Z",
"organization_id": "{organizationId}",
"data": {
"id": "{terminal_id}|2026-10|{transaction_id}",
"terminal_id": "{terminal_id}",
"transaction_id": "{transaction_id}",
"external_transaction_id": "{external_transaction_id}",
"store_id": "{store_id}",
"receipt_number": "R-000123",
"issued_at": "2026-10-26T03:15:42Z",
"status": "Completed",
"total_amount": 1100,
"tax_amount": 100,
"currency": "JPY",
"payment_method": "...",
"item_count": 2,
"source": "...",
"receipt_url": "..."
}
}
Both the envelope (id, event, timestamp, organization_id, data) and the fields inside data are snake_case. data carries nothing that identifies the customer. For line items or member details, look the sale up by terminal_id and transaction_id with the Transactions API or the Sales API.
When receipt.created is sent, and when it is not
- Sent: when the sale is stored in ReceiptRoller for the first time. Once per sale
- Not sent: when the POS vendor resends the same sale, on a routine re-sync, or when the same period is imported from CSV again. These rewrite a sale that already exists, so nothing is announced
- Not sent: for a deleted sale
- Not filtered by status: a transaction first stored as a void (
Voided) or refund (Refunded) is sent too. The server above skips these without posting
receipt.created is the sales event this quickstart uses. The events actually sent today are receipt.created, order.created, order.updated and test.ping. Other event names (such as receipt.updated) can be subscribed to but are not sent yet.
Step 6: Check the delivery log
curl https://receiptroller.io/api/v1/webhooks/{webhook_id}/deliveries \
-H "Authorization: Bearer {access_token}"
Returns the 50 most recent delivery attempts, newest first. Each retry is its own row, and attemptCount says which attempt it was.
[
{
"id": "wdl_xxxxxxxxxxxx",
"webhookId": "wh_xxxxxxxxxxxxxxxx",
"eventType": "receipt.created",
"httpStatus": 200,
"success": true,
"attemptCount": 1,
"errorMessage": null,
"attemptedAt": "2026-10-26T03:15:44.0000000"
}
]
An attempt that could not connect has httpStatus 0, with the reason in errorMessage.
Retries, and when a webhook is switched off
- A
2xxresponse is success. Any other status, a connection error, or no response within 10 seconds is a failure - A failed delivery is retried after 1 minute, 10 minutes and 1 hour: at most four attempts including the first. Retries carry the same body and the same
X-RR-Delivery - After the fourth failure the webhook is switched off (
active: false) and later events are not sent to it. Events that happen while it is off are not held for later - Once your receiver is fixed, switch it back on:
curl -X PATCH https://receiptroller.io/api/v1/webhooks/{webhook_id} \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{ "active": true }'
The server above answers 502 when it cannot post to Slack, so the retry posts it again. If Slack is down for more than an hour, that event fails four times and the webhook is switched off. If you would rather risk losing a message than have the webhook stop, answer 200 first and send to Slack afterwards.
Next steps
-
Connect an AI agent to ReceiptRoller over MCPConnect Claude as an MCP connector, then build your own MCP client with dynamic client registration, PKCE and JSON-RPC calls over HTTP.
-
Show a store's reservations in your own calendarFetch the next 7 days of a store's reservations with the Reservations API and write them to an iCalendar (.ics) file with Node.js.
-
Sync your product catalogue with ReceiptRollerBuild a Node.js script that reads a local CSV or JSON file, matches products by SKU, and creates or patches them through the Products API.