Gareth Wilson Gareth Wilson

Guide to Helcim Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAll Tools > Integrations > Webhooks
EndpointOne Deliver URL per account. HTTPS, and it can't contain "Helcim"
Subscription modelA toggle per event
SignatureStandard Webhooks HMAC-SHA256
Headerswebhook-id, webhook-timestamp, webhook-signature
SecretverifierToken, bare base64 with no whsec_ prefix
BodyJSON, thin
Event routingBody field type
RetriesImmediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours
Timeout, orderingNot documented
Source IPsNot published

Events and payloads

EventTriggered WhenCommon Use Cases
cardTransactionA card transaction occurs on the merchant account (approved or declined)Update an order's status, trigger a receipt, complete a checkout flow
terminalCancelA 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:

FieldMeaning
data.cancelledAtWhen the payment was cancelled (ISO 8601)
data.currency / data.transactionAmountCurrency and amount of the cancelled payment
data.deviceCodeThe payment device the cancellation happened on
data.customerCode / data.invoiceNumberCustomer 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

  1. Log in to Helcim and click All Tools, then Integrations, then Webhooks.
  2. Toggle Webhooks ON.
  3. Enter your Deliver URL. It must use https, and it must not contain the word "Helcim" anywhere, including the path, so /webhooks/helcim won't be accepted. Use something like /hooks/payments.
  4. Turn on Card Transaction and, if you use readers in API mode, Processing Account Cancel.
  5. Click Save.
  6. Copy the verifierToken shown on the same page and store it as HELCIM_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 deviceCode or 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.