Verifying Signatures
Anyone can send a request to your webhook URL. Check the signature of every request before you trust it: a valid signature proves that Mekari POS sent this exact body and that nobody changed it on the way.
Request Headers
Every event, including webhook.test, is a POST with these headers:
| Header | Value |
|---|---|
X-Mekari-Signature | v1= followed by the HMAC-SHA256 signature in lower-case hex |
X-Mekari-Timestamp | When this attempt was signed, in Unix seconds, for example 1760000000 |
X-Mekari-Key-Version | The version of the secret that signed it, for example 1 |
X-Mekari-Delivery-ID | The delivery: one event to one endpoint. The same on every retry of that delivery. |
X-Mekari-Event-ID | The event. Equal to the body's id, and the same on every retry. |
Content-Type | application/json |
User-Agent | mekaripos-webhooks/1.0 |
The Signed String
The signature is computed over five values joined with a dot (.):
<X-Mekari-Timestamp>.<X-Mekari-Delivery-ID>.<X-Mekari-Event-ID>.<X-Mekari-Key-Version>.<raw body>
- The key is the signing secret string exactly as the API returned it, as UTF-8 bytes. Do not base64-decode it, even though it looks like base64url.
- The raw body is the request body byte for byte, as it arrived. Do not parse the JSON and encode it again: a different key order, spacing or escaping changes the signature.
How to Verify
- Read the raw request body before any JSON parsing.
- Read the five headers. If one is missing, or the signature does not start with
v1=, reject the request. - Pick the secret for
X-Mekari-Key-Version. If you have no secret for that version, answer503(see Choosing a Status). - Reject the request if
X-Mekari-Timestampis more than 5 minutes away from your current time. Each attempt is signed again with a fresh timestamp, so retries pass this check. Keep your server clock in sync (NTP). - Build the signed string and compute HMAC-SHA256 with the secret. Write the result as lower-case hex.
- Compare it with the value after
v1=using a constant-time comparison, never==. - Parse the JSON only after the signature is valid. Drop events whose
X-Mekari-Event-IDyou already processed (see Order and Duplicates).
Choosing a Status
| Check that failed | Answer | Effect |
|---|---|---|
Unknown X-Mekari-Key-Version | 503 | Retried later, so you can install a new secret without losing events |
| Missing headers, old timestamp, wrong signature | 401 | Not retried |
See Delivery and Retries for every status and its effect.
Rotating the Secret
POST /webhooks/endpoints/{key}/rotate-secret returns a new secret with a higher key_version. Changing the endpoint url with PUT does the same.
- From that moment, every new attempt is signed with the new secret, including retries of older events. Only a request that was already being sent at that moment can still carry the old key version.
- The previous secret is revoked after 24 hours. Keep it in your receiver until then, so such late requests still verify.
- Keep the secrets in your receiver stored by key version, and pick the one that
X-Mekari-Key-Versionnames.
Deploy the new secret quickly. Until it is deployed, your receiver cannot verify new requests. Answering 503 gets them retried, but only for about 11 hours (see Retries).
A safe rotation:
- Call
rotate-secretand store the new secret next to the old one, under itskey_version. - Deploy it to your receiver right away. Until it is deployed, requests with the new version get
503from your receiver and are retried. - Call
POST /webhooks/endpoints/{key}/testand check that it answersdelivered: true. - After 24 hours, delete the old secret.
Test Vector
Use these values to check your code. Your code must produce exactly the expected signature.
| Input | Value |
|---|---|
| Secret | sample_webhook_signing_secret_0000000000000 |
X-Mekari-Timestamp | 1760000000 |
X-Mekari-Delivery-ID | 11111111-1111-4111-8111-111111111111 |
X-Mekari-Event-ID | 22222222-2222-4222-8222-222222222222 |
X-Mekari-Key-Version | 1 |
Raw body (one line, no spaces, no newline at the end):
{"id":"22222222-2222-4222-8222-222222222222","type":"webhook.test","schema_version":1,"occurred_at":"2025-10-09T08:53:20Z","company_id":"sample-company-id","outlet_id":null,"data":{}}
Expected X-Mekari-Signature:
v1=46e99337b046d5e13788b3480e7e51e72b10632dda67cc850d833acc96a8affa
The secret above is a sample. Real secrets come only from the API. The timestamp is in the past, so pass it as the current time when you run the vector, or your 5-minute check rejects it.
Code Samples
Each sample is one function: verify(rawBody, headers, secrets, now). headers holds the header names in lower case, secrets maps a key version (as a string) to its secret, and now is the current Unix time in seconds. It returns ok, or the reason the request is not valid: missing_headers, unknown_key_version, bad_timestamp or bad_signature.
Node.js
Node.js 16 or later, no extra packages. The Express route uses express.raw so the body stays a Buffer.
const crypto = require("crypto");
const TOLERANCE_SECONDS = 300;
// rawBody: the exact request body as a Buffer. Never JSON.stringify(req.body):
// re-encoded JSON does not match the signature.
function verify(rawBody, headers, secrets, now = Math.floor(Date.now() / 1000)) {
const timestamp = headers["x-mekari-timestamp"] || "";
const deliveryId = headers["x-mekari-delivery-id"] || "";
const eventId = headers["x-mekari-event-id"] || "";
const keyVersion = headers["x-mekari-key-version"] || "";
const signature = headers["x-mekari-signature"] || "";
if (!timestamp || !deliveryId || !eventId || !keyVersion || !signature.startsWith("v1=")) {
return "missing_headers";
}
const secret = secrets[keyVersion];
if (!secret) return "unknown_key_version";
if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) {
return "bad_timestamp";
}
// The key is the secret string itself (UTF-8); do not base64-decode it.
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${deliveryId}.${eventId}.${keyVersion}.`)
.update(rawBody)
.digest("hex");
const received = Buffer.from(signature.slice(3));
const wanted = Buffer.from(expected);
const same = received.length === wanted.length && crypto.timingSafeEqual(received, wanted);
return same ? "ok" : "bad_signature";
}
// Express
const express = require("express");
const app = express();
const secrets = { "1": process.env.MEKARI_POS_WEBHOOK_SECRET_V1 };
// express.raw, not express.json: the signature needs the raw bytes. Raise the
// 100 kb default limit: a full order body can be larger, and a 413 is not retried.
app.post("/hooks/mekari-pos", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
const result = verify(req.body, req.headers, secrets);
if (result !== "ok") {
return res.sendStatus(result === "unknown_key_version" ? 503 : 401);
}
const event = JSON.parse(req.body.toString("utf8"));
// Save the event (skip it if event.id is already saved), answer now, process later.
res.sendStatus(200);
});
app.listen(3000);
PHP
PHP 7.4 or later, no extra packages.
<?php
const MEKARI_TOLERANCE_SECONDS = 300;
// $rawBody: file_get_contents('php://input'). Never json_encode(json_decode(...)):
// re-encoded JSON does not match the signature.
function verify(string $rawBody, array $headers, array $secrets, ?int $now = null): string
{
$now = $now ?? time();
$timestamp = $headers['x-mekari-timestamp'] ?? '';
$deliveryId = $headers['x-mekari-delivery-id'] ?? '';
$eventId = $headers['x-mekari-event-id'] ?? '';
$keyVersion = $headers['x-mekari-key-version'] ?? '';
$signature = $headers['x-mekari-signature'] ?? '';
if ($timestamp === '' || $deliveryId === '' || $eventId === '' || $keyVersion === ''
|| strncmp($signature, 'v1=', 3) !== 0) {
return 'missing_headers';
}
if (!isset($secrets[$keyVersion]) || !is_string($secrets[$keyVersion]) || $secrets[$keyVersion] === '') {
return 'unknown_key_version';
}
if (!ctype_digit($timestamp) || abs($now - (int) $timestamp) > MEKARI_TOLERANCE_SECONDS) {
return 'bad_timestamp';
}
// The key is the secret string itself; do not base64-decode it.
$signed = $timestamp . '.' . $deliveryId . '.' . $eventId . '.' . $keyVersion . '.' . $rawBody;
$expected = hash_hmac('sha256', $signed, $secrets[$keyVersion]);
return hash_equals($expected, substr($signature, 3)) ? 'ok' : 'bad_signature';
}
// Receiver
$secrets = ['1' => getenv('MEKARI_POS_WEBHOOK_SECRET_V1')];
$rawBody = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$result = verify($rawBody, $headers, $secrets);
if ($result !== 'ok') {
http_response_code($result === 'unknown_key_version' ? 503 : 401);
exit;
}
$event = json_decode($rawBody, true);
// Save the event (skip it if $event['id'] is already saved), answer now, process later.
http_response_code(200);
In Laravel, use $request->getContent() for the raw body.
Ruby
Ruby 3.0 or later (for OpenSSL.secure_compare), no extra gems. The receiver uses Sinatra.
require "openssl"
MEKARI_TOLERANCE_SECONDS = 300
# raw_body: the exact request body (request.body.read in Rack and Sinatra,
# request.raw_post in Rails). Never params.to_json: re-encoded JSON does not
# match the signature.
def verify(raw_body, headers, secrets, now = Time.now.to_i)
timestamp = headers["x-mekari-timestamp"].to_s
delivery_id = headers["x-mekari-delivery-id"].to_s
event_id = headers["x-mekari-event-id"].to_s
key_version = headers["x-mekari-key-version"].to_s
signature = headers["x-mekari-signature"].to_s
if [timestamp, delivery_id, event_id, key_version].any?(&:empty?) || !signature.start_with?("v1=")
return "missing_headers"
end
secret = secrets[key_version]
return "unknown_key_version" if secret.nil?
unless timestamp.match?(/\A\d+\z/) && (now - timestamp.to_i).abs <= MEKARI_TOLERANCE_SECONDS
return "bad_timestamp"
end
# The key is the secret string itself; do not base64-decode it.
signed = "#{timestamp}.#{delivery_id}.#{event_id}.#{key_version}.".b + raw_body.b
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
OpenSSL.secure_compare(expected, signature.delete_prefix("v1=")) ? "ok" : "bad_signature"
end
# Sinatra
require "sinatra"
require "json"
SECRETS = { "1" => ENV["MEKARI_POS_WEBHOOK_SECRET_V1"] }.freeze
post "/hooks/mekari-pos" do
raw_body = request.body.read
headers = %w[timestamp delivery-id event-id key-version signature].to_h do |name|
["x-mekari-#{name}", request.get_header("HTTP_X_MEKARI_#{name.upcase.tr('-', '_')}")]
end
result = verify(raw_body, headers, SECRETS)
halt(result == "unknown_key_version" ? 503 : 401) unless result == "ok"
event = JSON.parse(raw_body)
# Save the event (skip it if event["id"] is already saved), answer now, process later.
status 200
end
Java
Java 17 or later (for HexFormat), no extra libraries.
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class MekariWebhookVerifier {
private static final long TOLERANCE_SECONDS = 300;
private MekariWebhookVerifier() {}
// rawBody: the exact request body bytes (for example @RequestBody byte[] in
// Spring). Never re-serialize a parsed object: re-encoded JSON does not
// match the signature.
public static String verify(byte[] rawBody, Map<String, String> headers, Map<String, String> secrets, long now)
throws GeneralSecurityException {
String timestamp = headers.getOrDefault("x-mekari-timestamp", "");
String deliveryId = headers.getOrDefault("x-mekari-delivery-id", "");
String eventId = headers.getOrDefault("x-mekari-event-id", "");
String keyVersion = headers.getOrDefault("x-mekari-key-version", "");
String signature = headers.getOrDefault("x-mekari-signature", "");
if (timestamp.isEmpty() || deliveryId.isEmpty() || eventId.isEmpty() || keyVersion.isEmpty()
|| !signature.startsWith("v1=")) {
return "missing_headers";
}
String secret = secrets.get(keyVersion);
if (secret == null) {
return "unknown_key_version";
}
if (!timestamp.matches("\\d{1,18}") || Math.abs(now - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) {
return "bad_timestamp";
}
// The key is the secret string itself (UTF-8); do not Base64-decode it.
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String prefix = timestamp + "." + deliveryId + "." + eventId + "." + keyVersion + ".";
mac.update(prefix.getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
byte[] received = signature.substring(3).getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), received) ? "ok" : "bad_signature";
}
}
Spring Boot receiver (a fragment: imports and the secrets field are left out):
@PostMapping(path = "/hooks/mekari-pos", consumes = "application/json")
public ResponseEntity<Void> receive(@RequestBody byte[] rawBody, @RequestHeader HttpHeaders httpHeaders)
throws GeneralSecurityException {
Map<String, String> headers = new HashMap<>();
for (String name : List.of("timestamp", "delivery-id", "event-id", "key-version", "signature")) {
String value = httpHeaders.getFirst("X-Mekari-" + name);
if (value != null) {
headers.put("x-mekari-" + name, value);
}
}
String result = MekariWebhookVerifier.verify(rawBody, headers, secrets, Instant.now().getEpochSecond());
if (!result.equals("ok")) {
return ResponseEntity.status(result.equals("unknown_key_version") ? 503 : 401).build();
}
// Parse rawBody, save the event (skip it if its id is already saved), answer now, process later.
return ResponseEntity.ok().build();
}
secrets is a Map<String, String> from key version to secret, loaded from your secret store.