Skip to main content
Webhooks are a way for your application to receive real-time notifications from the API when a specific event occurs. For example, you can use webhooks to receive notifications when a new project is created.

Example

Webhook for Project Creation

Create a new project and receive a webhook notification.

Securing your webhook

Every webhook we send is signed so that your server can confirm it genuinely came from Opus and was not modified in transit. The signature is built from a secret key that only you and Opus share — it is never sent in the request.
Your secret key is your Opus API secret key (the sk-… value from your API settings). Opus signs each webhook with your organization’s API secret key, so verify with that same value. If your organization has multiple API keys, the first key created is the one used for signing — keep it active, and if you rotate keys, update the secret on your server to match.
Each POST request carries three headers:
HeaderWhat it isWhy it’s there
X-Opus-SignatureAn HMAC-SHA256 hash of the request body combined with the salt, keyed with your secret.Authenticity & integrity — only a party holding the secret can produce it, so a valid signature proves the payload is genuine and unaltered.
X-Opus-Salt8 random bytes (hex), regenerated for every request.Mixed into the signed input so two identical payloads still produce different signatures.
X-Opus-TimestampThe Unix time (in seconds) when the webhook was sent.Lets you reject obviously old deliveries. See the note on replay protection below.

How the signature is computed

We generate the value of X-Opus-Signature like this:
signature = HMAC-SHA256(secretKey, body + salt)
Where secretKey is your Opus API secret key (the sk-… value — see the note above), body is the exact raw JSON request body, and salt is the value sent in X-Opus-Salt. Because the salt is appended to the body before hashing, you must use the raw body bytes — re-serializing the parsed JSON may change whitespace or key order and break the comparison.
The timestamp is not part of the signed input — only body + salt is hashed. The freshness check in Step 4 is therefore best-effort: it rejects stale deliveries, but a determined attacker who captures a request could replay it with a fresh timestamp. For strong replay protection, also record the X-Opus-Salt (or the signature) of each request you accept and reject any value you have already seen. See Replay protection below.

Verifying a webhook on your server

1

Step 1: Read the raw request body

Capture the request body exactly as received, before any JSON parsing. Also read the X-Opus-Signature, X-Opus-Salt, and X-Opus-Timestamp headers.
2

Step 2: Recompute the signature

Calculate HMAC-SHA256(secretKey, body + salt) using your shared secret and the salt from the header.
3

Step 3: Compare the signatures

Compare your computed value against X-Opus-Signature using a constant-time comparison. Decode both values to fixed-length buffers and check their lengths match before comparing — crypto.timingSafeEqual throws if the buffers differ in length, so a malformed or empty signature header must be rejected rather than allowed to crash your handler. If they don’t match, reject the request.
4

Step 4: Check the timestamp (recommended)

Reject the request if X-Opus-Timestamp is outside an acceptable window (for example, more than 5 minutes from the current time) to drop obviously old deliveries.
5

Step 5: Reject replays (recommended)

Record the X-Opus-Salt of each request you accept (e.g. in a short-lived cache keyed by salt). If you see a salt you have already processed, reject the request as a replay.

Example (Node.js)

import crypto from 'crypto'

const seenSalts = new Set() // use a TTL cache (e.g. Redis) in production

function verifyWebhook(rawBody, headers, secretKey) {
  const salt = headers['x-opus-salt']
  const timestamp = parseInt(headers['x-opus-timestamp'], 10)
  const received = headers['x-opus-signature']

  if (!received || !salt || Number.isNaN(timestamp)) return false

  // Step 2: recompute the signature
  const expected = crypto
    .createHmac('sha256', secretKey)
    .update(rawBody + salt)
    .digest('hex')

  // Step 3: constant-time compare — guard length first so a malformed
  // signature is rejected instead of throwing a RangeError.
  const a = Buffer.from(received, 'hex')
  const b = Buffer.from(expected, 'hex')
  const signatureValid = a.length === b.length && crypto.timingSafeEqual(a, b)
  if (!signatureValid) return false

  // Step 4: reject stale requests (5-minute window)
  const fresh = Math.abs(Math.floor(Date.now() / 1000) - timestamp) < 300
  if (!fresh) return false

  // Step 5: reject replays
  if (seenSalts.has(salt)) return false
  seenSalts.add(salt)

  return true
}
Keep your secret key private and store it securely. Anyone with the key can forge valid signatures. If you suspect it has leaked, rotate it.