# Webhook Security

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:

```text
sha256=BASE64_HMAC_DIGEST
```

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

```text
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

```java
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:

```java
@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

```javascript
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](/webhooks/events) for the signed payload shapes and [Receipt Webhooks](/webhooks/receipt-webhooks) for `ReceiptEnvelope` processing.