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
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.objectanddata.idname the object; other fields ofdatadepend 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.paidcarries the whole order so a shop can mark it paid without another call:idisorder-paid:<orderId>anddatais{ giepOrderId, externalOrderId?, paymentId, amount: { amount, currency }, lines: [{ externalProductId, title, quantity, unitPrice, kind?, inclusive?, code?, rateBps? }], customerEmail?, paidAt, kind?, offerId? }. Forkind: "upsell",amountandlinesdescribe 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:
- Use the raw request body exactly as received, before any JSON parsing or re-serialising.
- Reject when the secret is missing, the header is missing or malformed, or
tis more than 300 seconds from your clock. - Accept if any
v1value matches (compare in constant time). - Then deduplicate on
GIEP-Event-Id: deliveries are at least once. - Answer
2xxquickly and do the work afterwards.
Node.js (Express shown; any framework that gives you the raw bytes works):
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
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:
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
2xxwithin 10 seconds. A3xx(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 isfailed. Retry now in the delivery log (orPOST /v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retry) sends it again. - Events can arrive out of order and more than once. Use
GIEP-Event-Idand 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
v1values, 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).