Receive new sales by webhook and post them to Slack

Quickstart Webhook receipt.created Signature verification Slack Node.js

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. localhost and 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-Signature is sha256= 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-Delivery is the event's id, the same value as the payload's id. 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.created is not filtered by status. To post sales only, the server acknowledges events whose status is Voided or Refunded without 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 200 either way. Read success and httpStatus to see whether the delivery got through. httpStatus 0 means the connection itself failed (check the URL, the tunnel and the TLS certificate)
  • httpStatus 401 means your receiver rejected the signature. Check that RR_WEBHOOK_SECRET matches the secret you 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
  • 404 means 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 2xx response 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

Published: 2026-10-26 Updated: 2026-10-26