Skip to content

Webhooks and signature verification

Configure endpoints in the workspace (Settings > Webhooks) or with /v1/webhook-endpoints. Each endpoint belongs to one store, must be https:// on a public address (private, loopback and link-local addresses are refused, and checked again on every delivery; redirects are not followed) and has its own signing secret (whsec_…), shown once at creation or rotation. A store can have up to 10 endpoints.

Event types

Event When
payment.succeeded A payment reached succeeded.
payment.failed A payment reached failed.
payment.requires_action The customer must authenticate.
payment.unknown The processor did not answer in time; reconciliation follows. Do not fulfil and do not retry with a new key.
refund.succeeded, refund.failed A refund was confirmed or refused by the processor.
order.paid A checkout order is paid (also sent for each one-click upsell charged after it, with kind: "upsell"). Fulfil on this.
checkout_session.paid A checkout session is paid.
alert.received, alert.resolved, alert.manual_review Pre-dispute alert lifecycle.
webhook.test Sent by "Send test event" / POST /v1/webhook-endpoints/{id}/test.

Request format

HTTP
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: GIEP-Webhooks/1.0
GIEP-Signature: t=1761480000,v1=5f0c…e9
GIEP-Event-Id: evt_7d0c3c1e-…

{
  "id": "evt_7d0c3c1e-…",
  "type": "payment.succeeded",
  "created": "2026-10-26T12:00:00.000Z",
  "data": { "object": "payment", "id": "<payment id>", "status": "succeeded", "source": "webhook" }
}
  • data.object and data.id name the object; other fields of data depend on the event. Treat the event as a pointer: for anything that matters, read the object with the API (GET /v1/payments/{id}, GET /v1/refunds/{id}, GET /v1/checkout-sessions/{id}).
  • order.paid carries the whole order so a shop can mark it paid without another call: id is order-paid:<orderId> and data is { giepOrderId, externalOrderId?, paymentId, amount: { amount, currency }, lines: [{ externalProductId, title, quantity, unitPrice, kind?, inclusive?, code?, rateBps? }], customerEmail?, paidAt, kind?, offerId? }. For kind: "upsell", amount and lines describe only the addition.

Verify the signature

GIEP-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with the endpoint's signing secret (use the whole whsec_… string as the key). Rules:

  1. Use the raw request body exactly as received, before any JSON parsing or re-serialising.
  2. Reject when the secret is missing, the header is missing or malformed, or t is more than 300 seconds from your clock.
  3. Accept if any v1 value matches (compare in constant time).
  4. Then deduplicate on GIEP-Event-Id: deliveries are at least once.
  5. Answer 2xx quickly and do the work afterwards.

Node.js (Express shown; any framework that gives you the raw bytes works):

JavaScript
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

export function verifyGiepSignature(rawBody, header, secret, toleranceSec = 300) {
  if (!secret || !header) return false; // fail closed
  let t;
  const sigs = [];
  for (const part of header.split(',')) {
    const [k, v] = part.split('=', 2).map((s) => s.trim());
    if (k === 't' && /^\d+$/.test(v ?? '')) t = Number(v);
    else if (k === 'v1' && v) sigs.push(v.toLowerCase());
  }
  if (t === undefined || sigs.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
  const expected = createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${t}.`), Buffer.from(rawBody)]))
    .digest();
  return sigs.some((s) => /^[0-9a-f]{64}$/.test(s) && timingSafeEqual(Buffer.from(s, 'hex'), expected));
}

const app = express();
app.post('/giep/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyGiepSignature(req.body, req.header('giep-signature'), process.env.GIEP_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString('utf8'));
  // if (alreadyProcessed(event.id)) return res.sendStatus(200);
  res.sendStatus(200);
  // handle event.type ...
});

PHP (the same check the GIEP WooCommerce plugin uses):

PHP
<?php
function giep_verify_signature(string $secret, string $header, string $rawBody, int $tolerance = 300): bool
{
    if ($secret === '' || $header === '') {
        return false; // fail closed
    }
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        $kv = explode('=', trim($part), 2);
        if (count($kv) !== 2) {
            continue;
        }
        if ($kv[0] === 't' && ctype_digit($kv[1])) {
            $timestamp = (int) $kv[1];
        } elseif ($kv[0] === 'v1') {
            $signatures[] = strtolower($kv[1]);
        }
    }
    if ($timestamp === null || !$signatures || abs(time() - $timestamp) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach ($signatures as $candidate) {
        if (hash_equals($expected, $candidate)) {
            return true;
        }
    }
    return false;
}

$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_GIEP_SIGNATURE'] ?? '';
if (!giep_verify_signature(getenv('GIEP_WEBHOOK_SECRET') ?: '', $header, $rawBody)) {
    http_response_code(401);
    exit;
}
$event = json_decode($rawBody, true);
http_response_code(200);

Python:

Python
import hashlib
import hmac
import time


def verify_giep_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    if not secret or not header:
        return False  # fail closed
    t, sigs = None, []
    for part in header.split(","):
        k, _, v = part.strip().partition("=")
        if k == "t" and v.isdigit():
            t = int(v)
        elif k == "v1" and v:
            sigs.append(v.lower())
    if t is None or not sigs or abs(int(time.time()) - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)


# Flask: verify_giep_signature(request.get_data(), request.headers.get("GIEP-Signature", ""), SECRET)

The Node and Python samples were checked against signatures produced by GIEP's own signer (signGiepWebhook in packages/domain/src/signature.ts): valid, rotated (two v1 values), stale and tampered cases. The PHP function is the one in integrations/woocommerce-plugin/giep-checkout/giep-checkout.php, covered by the plugin's PHP tests.

Retries, ordering and rotation

  • A delivery succeeds on any 2xx within 10 seconds. A 3xx (not followed), any other status, a timeout or a network error is retried with backoff: 30 s, 1 min, 2 min, 4 min … capped at 1 hour, up to 8 attempts, then the delivery is failed. Retry now in the delivery log (or POST /v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retry) sends it again.
  • Events can arrive out of order and more than once. Use GIEP-Event-Id and the object's own status (read it with the API) rather than the order of arrival.
  • Rotate secret issues a new secret at once and the old one stops signing immediately. To rotate without failed deliveries, have your receiver accept both secrets for a short while (the samples accept several v1 values, but each delivery is signed with one secret, so try each of your secrets).
  • A disabled or deleted endpoint gets nothing. If the secret cannot be read GIEP does not send (it never sends an unsigned webhook).