Developers
Set your API contextPick a region and space ID once. Every sample fills in your base URL.
Where do I find these?

In Coevera: open your space's API application and choose Show Access. It shows the space ID and the base URL, whose host starts with your region.

From the API: if you know the space ID, the discovery endpoint GET /api/discovery/spaces/{space_id} returns its region and base URLs, without credentials.

Only the region and space ID are kept, in this browser. Never paste a key or password here.

More in the quickstart

WebhooksSecurity and signatures

Security and signatures

Prove that a delivery came from Coevera and was not changed on the way. Check a signature in your browser, then do the same in your receiver.

Answer

WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the webhook's signature value, sent with WebHook-Signature-Version: v1. Compute it over the bytes you received, before parsing, and compare in constant time. The signature has no timestamp or nonce, so also drop old events and deduplicate on event, entity.id, entity.revision and event_time.

A webhook signs its deliveries when it has a signature: a UUID you choose when you create it.

The v1 scheme

  • Key: the webhook's signature value, as UTF-8 bytes. The API stores it in lowercase and returns it that way: use it exactly as the API returns it.
  • Message: the exact bytes of the request body. A delivery body is one line of JSON with a space after every comma and colon, and non-ASCII characters escaped, so parsing it and serialising it again changes the bytes and the signature.
  • Output: lowercase hex, 64 characters, in the WebHook-Signature header, with WebHook-Signature-Version: v1.
  • Opt-in: a webhook without a signature sends no signature header, and its deliveries carry no authentication at all. Set one on every webhook.

Signature checker

Paste a delivery you received, the webhook's signature value and the WebHook-Signature header. The checker computes the signature with your browser's Web Crypto and compares the two. What you paste is not sent anywhere and not stored: reload the page and the fields are empty.

Signature checker

HMAC-SHA256 · Web Crypto

Runs entirely in your browser. Nothing you paste leaves this page: the checker makes no network calls. Still, prefer a test webhook's secret over a production one.

The checker needs JavaScript. Without it, compute the signature in code as shown on this page.

Verify in code

Read the raw body before any JSON parsing, compute the signature, and compare it with the header in constant time. These are the functions the webhook receiver recipe uses; in a shell, openssl computes the same value:

# signature_of BODY: lowercase hex HMAC-SHA256 of the exact bytes, keyed with the webhook's
# signature value. verify_signature BODY HEADER: succeeds when HEADER is that signature.
signature_of() {
  printf '%s' "$1" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.*= //'
}
verify_signature() {
  [ "$(signature_of "$1")" = "$(printf '%s' "$2" | tr -d '[:space:]' | tr 'A-F' 'a-f')" ]
}
// WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the
// webhook's signature value. Check the bytes as received, before parsing, in constant time.
function verifySignature(rawBody: Buffer, header: string | undefined, secret: string): boolean {
  if (!header) return false;
  const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex"));
  const received = Buffer.from(header.trim().toLowerCase());
  return expected.length === received.length && timingSafeEqual(expected, received);
}
# WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the
# webhook's signature value. Check the bytes as received, before parsing, in constant time.
def verify_signature(raw_body: bytes, header: Optional[str], secret: str) -> bool:
    if not header:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header.strip().lower())

REST only: this sample has no GraphQL version.

  • In frameworks that parse JSON for you, ask for the raw body: in Express, express.raw({ type: "application/json" }) instead of express.json(); in Flask, request.get_data().
  • Answer a delivery that fails the check with 401. Never with 403 or 410: either disables the webhook at once, with no retry and no notice (delivery and retries).

Replays and duplicates

The signature does not stop replays

There is no timestamp or nonce in the signed data, and no delivery ID. Anyone who captures a delivery can send it again, and it still verifies.

What the body does give you, inside the signed bytes:

  • event_time, when the change was saved, in UTC without an offset. A retry sends the same body for about 24 hours, so drop events older than 25 hours: anything older is a replay.
  • event, entity.id, entity.revision and event_time together identify an event. Keep the ones you processed for at least 25 hours and skip a repeat: a retry and a replay look the same.
  • team_space_id: when one receiver serves several spaces, check that it is the space the secret belongs to.

If you trim the record with load-only, keep id and revision in it. The webhook receiver recipe does all of this, and its self-test sends a retry, an old event and a forgery to prove it.

Keep the secret secret

  • Every read of a webhook returns its signature. Keep those responses out of logs, and give the Webhooks permission only to the applications that manage webhooks (permissions and scopes).
  • Use https:// URLs and keep insecure_ssl at false. The API accepts http://, but then the body and its signature travel in clear text.
  • To rotate the secret, update the webhook with a new signature. A retry carries the signature it was first sent with, so accept the old secret too for about 24 hours.

Limits

  • Version v1 is the only scheme. It has no timestamp, nonce or delivery ID.
  • Coevera does not sign deliveries of a webhook without a signature.
  • The checker on this page verifies v1 only.