Skip to content

Cheqi signs webhook requests with HMAC-SHA256 when a webhook secret is configured for the receiving company or client application.

Signature format

The X-Cheqi-Signature value has this form:

sha256=BASE64_HMAC_DIGEST

The digest is computed over the exact serialized JSON request body:

Base64(HMAC-SHA256(webhookSecret, rawRequestBody))

Verify the signature before parsing JSON. Parsing and re-serializing the body can change whitespace, property ordering, escaping, or omitted values and will produce a different digest.

Verification procedure

  1. Read the raw HTTP request bytes without modifying them.
  2. Read X-Cheqi-Signature.
  3. Compute HMAC-SHA256 using the webhook secret and raw body.
  4. Base64-encode the digest and prefix it with sha256=.
  5. Compare the supplied and expected signatures in constant time.
  6. Reject invalid signatures before parsing or enqueuing the event.

Java implementation

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class WebhookSignatureVerifier {
    private final byte[] secret;

    public WebhookSignatureVerifier(String webhookSecret) {
        this.secret = webhookSecret.getBytes(StandardCharsets.UTF_8);
    }

    public boolean isValid(byte[] rawBody, String suppliedSignature) {
        if (suppliedSignature == null) {
            return false;
        }

        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret, "HmacSHA256"));
            String expected = "sha256="
                    + Base64.getEncoder().encodeToString(mac.doFinal(rawBody));

            return MessageDigest.isEqual(
                    expected.getBytes(StandardCharsets.US_ASCII),
                    suppliedSignature.getBytes(StandardCharsets.US_ASCII)
            );
        } catch (Exception exception) {
            throw new IllegalStateException("Cannot verify webhook signature", exception);
        }
    }
}

Controller usage:

@PostMapping("/webhooks/cheqi")
public ResponseEntity<Void> receive(
        @RequestBody byte[] rawBody,
        @RequestHeader("X-Cheqi-Signature") String signature
) throws IOException {
    if (!signatureVerifier.isValid(rawBody, signature)) {
        return ResponseEntity.status(401).build();
    }

    WebhookEvent event = objectMapper.readValue(rawBody, WebhookEvent.class);
    webhookQueue.enqueue(event);
    return ResponseEntity.ok().build();
}

Node.js implementation

import crypto from "node:crypto";
import express from "express";

function verifyCheqiSignature(rawBody, suppliedSignature, webhookSecret) {
  if (!suppliedSignature) return false;

  const expected =
    "sha256=" +
    crypto.createHmac("sha256", webhookSecret).update(rawBody).digest("base64");

  const supplied = Buffer.from(suppliedSignature, "ascii");
  const calculated = Buffer.from(expected, "ascii");

  return (
    supplied.length === calculated.length &&
    crypto.timingSafeEqual(supplied, calculated)
  );
}

const app = express();

app.post(
  "/webhooks/cheqi",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    if (
      !verifyCheqiSignature(
        req.body,
        req.header("X-Cheqi-Signature"),
        process.env.CHEQI_WEBHOOK_SECRET
      )
    ) {
      return res.sendStatus(401);
    }

    await webhookQueue.enqueue(JSON.parse(req.body.toString("utf8")));
    return res.sendStatus(200);
  }
);

Secret handling

  • Store the webhook secret in a managed secret store or environment variable.
  • Never commit it to source control.
  • Never log the secret, the signature input, unwrapped AES keys, or decrypted documents.
  • Rotate a compromised secret and update every receiver atomically.
  • Use separate secrets and endpoints for test, sandbox, and production.

Encrypted payload handling

HMAC verification authenticates the webhook transport. Receipt and credit-note contents remain protected separately by recipient encryption.

For RECEIPT_CREATED and CREDIT_NOTE_CREATED:

  • Select the private key corresponding to the event's publicKey snapshot.
  • Use recipientKeyAlgorithm and the SDK to unwrap encryptedEnvelopeKey.
  • Decrypt encryptedEnvelope into ReceiptEnvelope.
  • Treat all ReceiptEnvelope.documents content as sensitive.
  • Keep historical private keys available for receipts encrypted before rotation.

For RETURN_REQUESTED, decrypt encryptedCreditNoteInitiationRequest with encryptedSymmetricKey and deserialize the result as CreditNoteInitiationRequest.

The webhook never includes plaintext receipt documents, plaintext return requests, or separate encrypted customer details.

Endpoint hardening

  • Require HTTPS.
  • Limit accepted methods and content types.
  • Apply a reasonable body-size limit before buffering.
  • Rate-limit invalid requests without blocking legitimate retry bursts.
  • Respond with 401 for invalid signatures and do not disclose comparison details.
  • Persist accepted events before returning 2xx.
  • Make downstream processing idempotent.
  • Monitor signature failures, non-2xx responses, and processing backlogs.

IP allowlists can be used as defense in depth, but they do not replace HMAC verification.

Security checklist

  • Verify X-Cheqi-Signature against the exact raw body.
  • Use constant-time comparison.
  • Keep secrets out of code and logs.
  • Use HTTPS in every environment.
  • Enforce request-size and rate limits.
  • Keep historical envelope-decryption keys safely available.
  • Never log decrypted ReceiptEnvelope or CreditNoteInitiationRequest contents.
  • Process retries idempotently.

See Webhook Events for the signed payload shapes and Receipt Webhooks for ReceiptEnvelope processing.