# Guide to Helcim Webhooks: Features and Best Practices

[Helcim](https://www.helcim.com) 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 `POST`s 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/helcim/latest/cardTransaction.json), is just this:

```json
{
  "id": "25764674",
  "type": "cardTransaction"
}

```

Call [Get Card Transaction by ID](https://devdocs.helcim.com/reference/getcardtransaction) 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

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](/docs/cli) with a path that avoids the restricted word:

```bash
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](https://devdocs.helcim.com/docs/webhooks#verifying-a-webhook-event) 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:

```javascript
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:

```javascript
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](/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](/docs/retries) 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](/docs/connections) and [filters](/docs/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](/webhooks/guides/implement-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](https://hookdeck.com) can hold Helcim's notifications and retry them for you. [Sign up](https://dashboard.hookdeck.com/signup) and create a Helcim source to get started.