Skip to main content

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:

HeaderValue
X-Mekari-Signaturev1= followed by the HMAC-SHA256 signature in lower-case hex
X-Mekari-TimestampWhen this attempt was signed, in Unix seconds, for example 1760000000
X-Mekari-Key-VersionThe version of the secret that signed it, for example 1
X-Mekari-Delivery-IDThe delivery: one event to one endpoint. The same on every retry of that delivery.
X-Mekari-Event-IDThe event. Equal to the body's id, and the same on every retry.
Content-Typeapplication/json
User-Agentmekaripos-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​

  1. Read the raw request body before any JSON parsing.
  2. Read the five headers. If one is missing, or the signature does not start with v1=, reject the request.
  3. Pick the secret for X-Mekari-Key-Version. If you have no secret for that version, answer 503 (see Choosing a Status).
  4. Reject the request if X-Mekari-Timestamp is 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).
  5. Build the signed string and compute HMAC-SHA256 with the secret. Write the result as lower-case hex.
  6. Compare it with the value after v1= using a constant-time comparison, never ==.
  7. Parse the JSON only after the signature is valid. Drop events whose X-Mekari-Event-ID you already processed (see Order and Duplicates).

Choosing a Status​

Check that failedAnswerEffect
Unknown X-Mekari-Key-Version503Retried later, so you can install a new secret without losing events
Missing headers, old timestamp, wrong signature401Not 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-Version names.
warning

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:

  1. Call rotate-secret and store the new secret next to the old one, under its key_version.
  2. Deploy it to your receiver right away. Until it is deployed, requests with the new version get 503 from your receiver and are retried.
  3. Call POST /webhooks/endpoints/{key}/test and check that it answers delivered: true.
  4. After 24 hours, delete the old secret.

Test Vector​

Use these values to check your code. Your code must produce exactly the expected signature.

InputValue
Secretsample_webhook_signing_secret_0000000000000
X-Mekari-Timestamp1760000000
X-Mekari-Delivery-ID11111111-1111-4111-8111-111111111111
X-Mekari-Event-ID22222222-2222-4222-8222-222222222222
X-Mekari-Key-Version1

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.