Zorveq / Helcim Webhook Integration Guide
← Dashboard

How to receive Helcim webhooks on your website

Zorveq operates a central webhook router. When a payment event occurs in Helcim, Helcim notifies our router at a single URL, and the router immediately forwards a copy to every registered company endpoint — including yours.

What you need to do

  1. Create an endpoint (URL) on your website that accepts POST requests.
  2. Send that URL to Zorveq so we can register it in the router.
  3. Verify the X-Webhook-Signature header on every incoming request.
  4. Process the event and respond with HTTP 200 within 30 seconds.
1

Create a webhook endpoint on your website

Add a new URL to your website that will receive POST requests with a JSON body. The path can be anything you like — for example:

https://yourwebsite.com/webhooks/inbound

Send this URL to Zorveq and we will register it in the router dashboard. Once active, every Helcim event will be forwarded here automatically.

2

Verify the request signature

Every forwarded request includes an X-Webhook-Signature header containing an HMAC-SHA256 signature of the raw request body, signed with a secret we generate for your endpoint.

Always verify this signature before processing the event. Reject any request where the signature is missing or does not match — this protects you from spoofed requests.

Your signing secret will be provided by Zorveq when your endpoint is registered. Store it securely in an environment variable — never hard-code it or commit it to source control.
Request headers sent by the router
Content-Type application/json
X-Webhook-Signature sha256=<hex digest>
User-Agent Zorveq-Webhook-Router/1.0
webhook.php
<?php

// Retrieve the raw POST body BEFORE reading $_POST
$payload = file_get_contents('php://input');

// Your signing secret (store in an environment variable)
$secret = getenv('ZORVEQ_WEBHOOK_SECRET');

// Extract the signature header
$sigHeader = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

// Compute expected signature
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);

// Reject if signature does not match
if (!hash_equals($expected, $sigHeader)) {
    http_response_code(401);
    exit('Invalid signature');
}

// Decode the event
$event = json_decode($payload, true);
$eventType = $event['eventType'] ?? $event['type'] ?? 'unknown';

switch ($eventType) {
    case 'payment.success':
        // handle successful payment
        $amount    = $event['data']['amount'] ?? null;
        $invoiceId = $event['data']['invoiceNumber'] ?? null;
        // ... your logic here
        break;

    case 'payment.failed':
        // handle failed payment
        break;

    case 'refund.success':
        // handle refund
        break;

    default:
        // unknown event — log and ignore
        break;
}

// Respond 200 quickly so the router marks the delivery as successful
http_response_code(200);
echo json_encode(['status' => 'ok']);
webhook.js (Express)
const express = require('express');
const crypto  = require('crypto');
const router  = express.Router();

// Your signing secret (store in an environment variable)
const SECRET = process.env.ZORVEQ_WEBHOOK_SECRET;

// IMPORTANT: use express.raw() so we get the unmodified body bytes for signature verification
router.post('/webhooks/inbound', express.raw({ type: 'application/json' }), (req, res) => {
  const sigHeader = req.headers['x-webhook-signature'] || '';
  const expected  = 'sha256=' + crypto
    .createHmac('sha256', SECRET)
    .update(req.body)       // req.body is a Buffer when using express.raw()
    .digest('hex');

  // Reject if signature does not match
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sigHeader))) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(req.body);
  const eventType = event.eventType || event.type || 'unknown';

  switch (eventType) {
    case 'payment.success':
      // handle successful payment
      console.log('Payment received:', event.data);
      break;

    case 'payment.failed':
      // handle failed payment
      break;

    case 'refund.success':
      // handle refund
      break;

    default:
      console.log('Unhandled event type:', eventType);
  }

  // Respond 200 quickly
  res.status(200).json({ status: 'ok' });
});

module.exports = router;
webhook.py (FastAPI)
import hashlib
import hmac
import os
from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()

# Your signing secret (store in an environment variable)
SECRET = os.environ["ZORVEQ_WEBHOOK_SECRET"]


@app.post("/webhooks/inbound")
async def receive_webhook(
    request: Request,
    x_webhook_signature: str = Header(...),
):
    body = await request.body()

    # Compute expected signature
    expected = "sha256=" + hmac.new(
        SECRET.encode(), body, hashlib.sha256
    ).hexdigest()

    # Reject if signature does not match
    if not hmac.compare_digest(expected, x_webhook_signature):
        raise HTTPException(status_code=401, detail="Invalid signature")

    event = await request.json()
    event_type = event.get("eventType") or event.get("type") or "unknown"

    if event_type == "payment.success":
        # handle successful payment
        pass
    elif event_type == "payment.failed":
        # handle failed payment
        pass
    elif event_type == "refund.success":
        # handle refund
        pass

    # Respond 200 quickly
    return {"status": "ok"}
app/Http/Controllers/HelcimWebhookController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class HelcimWebhookController extends Controller
{
    public function handle(Request $request)
    {
        // Read raw body before Laravel parses it
        $payload   = $request->getContent();
        $secret    = config('services.zorveq.webhook_secret'); // env('ZORVEQ_WEBHOOK_SECRET')
        $sigHeader = $request->header('X-Webhook-Signature', '');

        // Compute expected signature
        $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);

        // Reject if signature does not match
        if (!hash_equals($expected, $sigHeader)) {
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        $event     = json_decode($payload, true);
        $eventType = $event['eventType'] ?? $event['type'] ?? 'unknown';

        match ($eventType) {
            'payment.success' => $this->handlePaymentSuccess($event),
            'payment.failed'  => $this->handlePaymentFailed($event),
            'refund.success'  => $this->handleRefund($event),
            default           => Log::info('Unhandled Helcim event', ['type' => $eventType]),
        };

        return response()->json(['status' => 'ok']);
    }

    private function handlePaymentSuccess(array $event): void
    {
        $amount    = $event['data']['amount']        ?? null;
        $invoiceId = $event['data']['invoiceNumber'] ?? null;
        // ... your logic here
    }

    private function handlePaymentFailed(array $event): void
    {
        // ... your logic here
    }

    private function handleRefund(array $event): void
    {
        // ... your logic here
    }
}

// In routes/api.php:
// Route::post('/webhooks/inbound', [HelcimWebhookController::class, 'handle'])
//      ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
3

Understand the event payload

The body is the original Helcim webhook JSON, forwarded verbatim. The structure follows Helcim's own webhook format. Key top-level fields:

Field Type Description
eventType string Event name, e.g. payment.success
id string Unique event ID (useful for deduplication)
createdAt string (ISO 8601) Timestamp of the event
data object Event-specific payload (transaction details, amounts, etc.)

Example payload:

POST body (JSON)
{
  "id": "evt_abc123",
  "eventType": "payment.success",
  "createdAt": "2026-06-11T14:30:00Z",
  "data": {
    "transactionId": "txn_xyz789",
    "invoiceNumber": "INV-0042",
    "amount": "150.00",
    "currency": "CAD",
    "cardType": "Visa",
    "last4": "4242",
    "customerName": "Jane Smith",
    "customerEmail": "jane@example.com"
  }
}

Refer to the Helcim webhook documentation for the full list of event types and their data shapes.

4

Respond with HTTP 200 quickly

The router waits up to 30 seconds for your endpoint to respond. If it times out or returns a 4xx/5xx status, the delivery is marked failed and retried up to 3 times with exponential backoff.

Best practice

  • Acknowledge the request immediately with 200 OK.
  • Do any heavy processing (database writes, emails, etc.) asynchronously in a background job.
  • Use the id field to deduplicate — retries send the same payload.

Troubleshooting

My endpoint is returning 401 — signature mismatch

The most common cause is computing the HMAC against a parsed or re-serialised body instead of the raw bytes.

  • In PHP: use file_get_contents('php://input'), never $_POST.
  • In Express: use express.raw() middleware on this route, not express.json().
  • In Laravel: use $request->getContent(), not $request->all().
  • Confirm the secret value matches exactly what Zorveq provided — no extra spaces or newlines.
I'm not receiving any webhooks
  • Confirm your endpoint URL is registered and marked Active in the Zorveq dashboard.
  • Check the Delivery Logs in the dashboard for error messages.
  • Make sure your URL is publicly reachable — localhost URLs will not work in production.
  • Verify your server accepts POST requests and does not redirect to HTTPS (redirects lose the body).
Laravel CSRF token mismatch

Exclude your webhook route from CSRF verification. Add it to $except in VerifyCsrfToken.php, or use the withoutMiddleware option in your route definition as shown in the Laravel example above.

The router retried but I already processed the event

Store the event id in your database and check for it before processing. If the ID already exists, return 200 OK immediately without re-processing. This makes your handler idempotent.

Need help?

Contact Zorveq at admin@zorveq.com to register your webhook URL, retrieve your signing secret, or troubleshoot delivery issues. Include your company name and the URL you want to register.