Guide to Helcim Webhooks: Features and Best Practices
Helcim processes card payments for merchants in Canada and the US, through its online payment tools and its in-person payment devices. Its webhooks are narrow: they tell you that a card transaction happened, approved or declined, or that a payment was cancelled on a reader before it was processed. They exist so that your checkout, point-of-sale or accounting system hears about a payment immediately instead of polling Helcim's API.
Helcim's webhook setup is small, but it has two traps: a URL rule that rejects many obvious paths, and a signing secret that isn't in the usual whsec_ form. Both are covered below, along with the two events and the retry schedule.
What are Helcim webhooks?
Each Helcim account has a single Deliver URL and a toggle per event type, set at All Tools > Integrations > Webhooks. An event is only sent when its toggle is on. Requests are JSON POSTs signed per Standard Webhooks, with the signing key derived from your account's verifierToken.
The bodies are notifications rather than records. A cardTransaction event contains the transaction ID and nothing else, so your handler looks up the transaction through Helcim's API before it can do anything useful.
Helcim webhook features
| Feature | Details |
|---|---|
| Configuration | All Tools > Integrations > Webhooks |
| Endpoint | One Deliver URL per account. HTTPS, and it can't contain "Helcim" |
| Subscription model | A toggle per event |
| Signature | Standard Webhooks HMAC-SHA256 |
| Headers | webhook-id, webhook-timestamp, webhook-signature |
| Secret | verifierToken, bare base64 with no whsec_ prefix |
| Body | JSON, thin |
| Event routing | Body field type |
| Retries | Immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours |
| Timeout, ordering | Not documented |
| Source IPs | Not published |
Events and payloads
| Event | Triggered When | Common Use Cases |
|---|---|---|
cardTransaction | A card transaction occurs on the merchant account (approved or declined) | Update an order's status, trigger a receipt, complete a checkout flow |
terminalCancel | A payment is cancelled on the device before processing (device in API mode) | Release a held order, tell the cashier or customer the payment was cancelled |
Helcim documents only these two events and doesn't say the list is complete, so acknowledge any type you don't recognise. The dashboard labels the second toggle Processing Account Cancel rather than terminalCancel.
The cardTransaction body, from the webhook samples library, is just this:
{
"id": "25764674",
"type": "cardTransaction"
}
Call Get Card Transaction by ID with id to learn the amount, card type, status and the rest.
terminalCancel puts its details under data:
| Field | Meaning |
|---|---|
data.cancelledAt | When the payment was cancelled (ISO 8601) |
data.currency / data.transactionAmount | Currency and amount of the cancelled payment |
data.deviceCode | The payment device the cancellation happened on |
data.customerCode / data.invoiceNumber | Customer and invoice, empty when not set |
Neither body has an event ID. Use the webhook-id header, which is unique per message and stays the same on retries.
Setting up Helcim webhooks
- Log in to Helcim and click All Tools, then Integrations, then Webhooks.
- Toggle Webhooks ON.
- Enter your Deliver URL. It must use
https, and it must not contain the word "Helcim" anywhere, including the path, so/webhooks/helcimwon't be accepted. Use something like/hooks/payments. - Turn on Card Transaction and, if you use readers in API mode, Processing Account Cancel.
- Click Save.
- Copy the
verifierTokenshown on the same page and store it asHELCIM_VERIFIER_TOKEN, exactly as shown.
To test from your machine, run the Hookdeck CLI with a path that avoids the restricted word:
npx hookdeck-cli listen 3000 helcim --path /hooks/payments
With Hookdeck. Create a Helcim source in Hookdeck and enter the token in the secret field as whsec_ followed by the token. Paste your verifierToken as whsec_ followed by the token, or verification fails. Hookdeck's source URLs don't contain "Helcim", so they pass the Deliver URL rule. The source accepts POST only, answers Helcim with 200 either way, and marks a request with a bad signature as failed verification so it isn't delivered.
Securing Helcim webhooks
Helcim's verification steps are the Standard Webhooks ones: build ${webhook-id}.${webhook-timestamp}.${body}, compute HMAC-SHA256 with the base64-decoded verifierToken as the key, base64-encode the result, and compare it with the entries in webhook-signature after their v1, prefix. The verifier below strips a whsec_ prefix if there is one and base64-decodes what's left, so it works with the bare token or the prefixed form:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
A route that looks up the transaction after verifying:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
const app = express();
app.post('/hooks/payments', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.HELCIM_VERIFIER_TOKEN;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
if (typeof event.type !== 'string') return res.sendStatus(400);
const messageId = req.headers['webhook-id'];
try {
if (event.type === 'cardTransaction') {
await lookups.enqueue({ messageId, transactionId: event.id }); // calls Get Card Transaction by ID
} else if (event.type === 'terminalCancel') {
if (!event.data || typeof event.data !== 'object') return res.sendStatus(400);
await orders.releaseHold(event.data.invoiceNumber, event.data.deviceCode);
}
} catch {
return res.sendStatus(503); // not processed, so don't acknowledge
}
return res.sendStatus(200);
});
lookups and orders stand in for your own code.
Helcim's Node sample decodes the token with new Buffer(verifierToken, "base64"), which is deprecated; Buffer.from does the same job.
Helcim states no timestamp tolerance. The standardwebhooks library defaults to 5 minutes, which is a problem behind Hookdeck: Hookdeck enforces no window, and a retried or replayed request still carries Helcim's original webhook-timestamp. A signature-only check like the one above avoids that.
Make Helcim webhooks production-ready. Hookdeck Event Gateway verifies Helcim's signatures with your
verifierToken, keeps each payment notification, and retries it into your service until the transaction lookup succeeds.
Helcim webhook limitations and pain points
A secret that isn't whsec_
The Problem: Verification fails even though you copied the token correctly.
Why It Happens: Helcim shows the verifierToken as bare base64. Code or tools that only base64-decode whsec_ secrets will use the token's text as the key instead.
Workarounds:
- Base64-decode the token yourself, or add the
whsec_prefix before handing it to a Standard Webhooks library that expects it.
How Hookdeck Can Help: Following the source's configuration alert (whsec_ plus the token) is all Hookdeck needs; from then on the key handling sits in one place.
The Deliver URL can't say "Helcim"
The Problem: Helcim refuses to save a perfectly ordinary URL.
Why It Happens: Helcim rejects any Deliver URL containing the word "Helcim".
Workarounds:
- Use a neutral path such as
/hooks/payments.
How Hookdeck Can Help: Hookdeck source URLs are random, so the rule never comes up, and your own route can be named whatever you like.
Notifications without data
The Problem: You can't act on a cardTransaction event without another API call, and that call can fail.
Why It Happens: Helcim sends only the transaction ID.
Workarounds:
- Do the lookup in a worker with its own retries, not in the webhook request.
How Hookdeck Can Help: Hookdeck can retry the event into your worker if the lookup fails, without involving Helcim again.
One URL for everything
The Problem: Staging and production can't each have their own endpoint on one account.
Why It Happens: Helcim allows a single Deliver URL per account.
Workarounds:
- Send everything to one receiver and route internally, for example by
deviceCodeor by the account each transaction belongs to.
How Hookdeck Can Help: Point the one Deliver URL at Hookdeck and fan events out to several destinations with connections and filters.
Best practices
Verify the raw body
Use express.raw({ type: '*/*' }), await request.text() in a Next.js route handler, or await request.body() in FastAPI, and disable CSRF on the route.
Deduplicate on webhook-id
Helcim retries up to 8 times over about 32 hours, and the body has no ID of its own. Store webhook-id before acting. See our guide to webhook idempotency.
Treat declines as transactions too
cardTransaction fires for declined cards as well as approved ones. Read the status from the transaction lookup before you mark anything as paid.
Don't allowlist IPs
Helcim publishes no source addresses. The signature is the only check available.
Conclusion
Helcim's webhooks are thin payment notifications with a standard signature and two quirks. Decode the bare verifierToken, keep the word "Helcim" out of your URL, look up each transaction before acting on it, and remember that declines arrive on the same event as approvals.
If the lookups or your service fail, Hookdeck Event Gateway can hold Helcim's notifications and retry them for you. Sign up and create a Helcim source to get started.