# Guide to Cyera Webhooks: Features and Best Practices

Cyera webhooks hand privacy work to your systems. When a data subject asks to see their data, have it deleted or opt out, Cyera Privacy sends your endpoint the request with the identifiers you need to find that person, and waits for you to report back when the job is done. If your organisation uses Cyera Privacy to automate data-subject requests across internal systems, webhooks are how those requests reach the systems Cyera has no built-in connector for.

This guide covers how Cyera Privacy webhook integrations work, the request types and their bodies, how to verify a request, the response codes and callbacks Cyera expects, and the practices that keep a Cyera receiver reliable.

## What are Cyera webhooks?

These are the webhooks of Cyera Privacy, the privacy-request product, not alerts from Cyera's data security platform. A webhook integration lets Cyera "dispatch privacy request processing to your systems". When a privacy request reaches the right stage of its lifecycle, Cyera sends a JSON `POST` to your endpoint. Your system does the work, such as exporting or deleting the person's data, and then confirms completion with a `PUT` to the callback URL in the request.

So a Cyera webhook is the first half of a two-step protocol. The response to the webhook says whether you accepted the request. The callback, sent later from your side, says how it ended.

Each request carries `X-Webhook-Timestamp` and `X-Webhook-Signature`. The signature is a base64 HMAC-SHA256 of the timestamp, a colon and the raw body, keyed with the Webhook Secret Cyera generates for the integration.

## Cyera webhook features

| Feature | Details |
| --- | --- |
| Configuration | A webhook integration in Cyera Privacy. The Webhook Secret is on its connection page |
| Request types | `health_check`, `access`, `deletion`, `opt_out`, `readiness_check` |
| Signature header | `X-Webhook-Signature`, bare standard base64 |
| Timestamp header | `X-Webhook-Timestamp`, Unix seconds |
| Scheme | HMAC-SHA256, base64 |
| Signed content | `<X-Webhook-Timestamp>:<raw body>`, with a colon |
| Secret | Generated by Cyera. The docs' examples show values starting `dgwebhook` |
| User agent | `privacyclient/1.0` |
| Expected responses | `201` when a request is created, `200` for health checks and reminders, 4xx or 5xx when not created |
| Completion | `PUT` to `webhook_callback_url` with an API key that has the Webhook Callback scope |
| Retries, timeout, replay window, source IPs | Not documented |

## Common events

Cyera calls these request types rather than events. The body's `request_type` says which one you have:

| `request_type` | What Cyera wants |
| --- | --- |
| `access` | Retrieve the data subject's personal data from your system |
| `deletion` | Delete the data subject's personal data from your system |
| `opt_out` | Process an opt-out for the data subject |
| `health_check` | Confirm the endpoint is reachable. Sent periodically and not tied to a privacy request |
| `readiness_check` | Answer a configured question, such as whether the system is ready. Sent only when a readiness check is configured on the integration |

Access, deletion and opt-out bodies also carry a `topic`. `privacy_request:create` and `opt_out:create` are new requests, and `privacy_request:reminder` and `opt_out:reminder` are reminders about requests that haven't been completed.

Every body has `request_type`, `integration_name`, `integration_kind` (always `webhook`) and a unique `webhook_request_id`. Here is Cyera's access request example:

```json
{
  "request_type": "access",
  "integration_name": "Webhook Integration",
  "integration_kind": "webhook",
  "topic": "privacy_request:create",
  "identifiers": {
    "email": [ { "email": "john@example.com" } ],
    "user_id": [ { "user_id": "100101" } ]
  },
  "webhook_callback_url": "https://acme.privacy.cyera.io/api/v2/webhooks/callback",
  "privacy_request_id": "4b7a02e2-361e-4957-9d69-b381e3f47758",
  "integration_id": "4b7a02e2-361e-4957-9d69-b381e3f47758",
  "webhook_request_id": "3aa32ca6-e60d-4b71-981a-d1ba258fd712"
}

```

`identifiers` groups values by category, each as an array: `email`, `phone`, `user_id`, `service_id`, `application_id`, `browser_id`, `advertising_id` and `social_media_id`. Opt-out bodies use `opt_out_request_id` and an integer `integration_state_id` instead of `privacy_request_id` and `integration_id`. Readiness checks add a `question` and its two `answers`, such as `["Yes", "No"]`. Cyera doesn't publish a deletion example, but documents the same fields for access and deletion.

## Setting up Cyera webhooks

1. Build an endpoint that accepts JSON `POST` requests. Cyera sends health checks to it periodically, so it needs to be up before you connect the integration.
2. In Cyera Privacy, create the webhook integration for your endpoint, and copy the Webhook Secret from the integration's connection page. Store it server-side, for example as `CYERA_WEBHOOK_SECRET`.
3. For callbacks, create an API key with the Webhook Callback scope under **Settings > API Keys > Create API Key**.

If you receive Cyera webhooks through Hookdeck, create a source with the Cyera type and use its URL as the integration's endpoint. The source is for Cyera Privacy's privacy-request webhooks. Paste the Webhook Secret from the webhook integration into it. The source accepts `POST` only, which is the method Cyera uses.

Read the section on response codes below before you put Hookdeck between Cyera and your service, because Cyera reads meaning into the status code you return.

### Response codes and callbacks

Cyera expects a `201 Created` when a request has been created successfully, and a `200 OK` for health checks and reminders. If a request has not been created, it expects a 4xx or 5xx, optionally with an error body such as `{"error": "Invalid request signature"}`.

Once the work is done, send a `PUT` to `webhook_callback_url` with your API key. The body repeats `webhook_request_id` and `request_type`, plus a `status`: `completed` or `ignored` for access and deletion, `complete` or `error` for opt-outs. Access callbacks can attach `results`, either as base64 files (200 MB in total) or as file paths in the cloud storage bucket integrated with Cyera. A readiness check callback sends an `answer` that matches one of the configured options. Cyera answers callbacks with `200`, or `400`, `404`, `409` (already processed, or the readiness check has expired) or `422`.

## Securing Cyera webhooks

Read `X-Webhook-Timestamp` and `X-Webhook-Signature`. Build the signed bytes from the timestamp exactly as received, a colon, and the raw request body. Compute an HMAC-SHA256 of them with the Webhook Secret as a UTF-8 key, base64-encode the digest, and compare it with the header in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyCyeraSignature(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !secret) return false;

  // Optional replay window, off by default. Retries and replays resend the original timestamp.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestampHeader)) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestampHeader)) > toleranceSeconds) return false;
  }

  // A colon, not a dot, between the timestamp and the body.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${timestampHeader}:`, 'utf8'), rawBody]))
    .digest('base64');

  // Compare the base64 strings exactly: base64 is case-sensitive.
  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/cyera', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.CYERA_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const valid = verifyCyeraSignature(
    req.body,
    req.get('x-webhook-signature'),
    req.get('x-webhook-timestamp'),
    secret
  );
  if (!valid) return res.status(401).json({ error: 'Invalid request signature' });

  const request = JSON.parse(req.body.toString('utf8'));
  const isNew = typeof request.topic === 'string' && request.topic.endsWith(':create');
  // Record the request durably here, then do the work and PUT to request.webhook_callback_url.
  res.sendStatus(isNew ? 201 : 200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import time

def verify_cyera_signature(raw_body: bytes, signature_header, timestamp_header, secret: str,
                           tolerance_seconds: int = 0) -> bool:
    if not signature_header or not timestamp_header or not secret:
        return False
    # Optional replay window, off by default.
    if tolerance_seconds > 0:
        if not (timestamp_header.isascii() and timestamp_header.isdigit()):
            return False
        if abs(time.time() - int(timestamp_header)) > tolerance_seconds:
            return False
    signed = timestamp_header.encode("utf-8") + b":" + raw_body
    expected = base64.b64encode(
        hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    )
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)

# In FastAPI: raw_body = await request.body()
# verify_cyera_signature(raw_body, request.headers.get("x-webhook-signature"),
#                        request.headers.get("x-webhook-timestamp"),
#                        os.environ["CYERA_WEBHOOK_SECRET"])

```

Cyera's examples in Shell, JavaScript, Python and Ruby all compute `HMAC-SHA256(secret, "<timestamp>:<payload>")` and base64-encode it, using a health check body. The separator is a colon. Code copied from a scheme that uses a dot, which is far more common, fails every time.

Cyera doesn't document a tolerance window for `X-Webhook-Timestamp`. The samples above leave the check off unless you pass a tolerance. Hookdeck applies no timestamp tolerance either, and its retries and replays resend the original header, so if you add a window, make it longer than any delay you allow for retries.

> **Make Cyera webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the base64 `X-Webhook-Signature` over the timestamp and raw body, stores every privacy request, and delivers it to your service with retries you control.

## Cyera webhook limitations and pain points

### Cyera reads meaning into the status code

**The Problem:** Cyera needs to know whether you created the request, and it learns that from the status code: `201` for created, `200` for health checks and reminders, 4xx or 5xx for not created.

**Why It Happens:** Cyera reads meaning into the response code, so the code your receiver returns is part of the protocol. Anything between Cyera and your code that answers on your behalf changes what Cyera sees. Cyera doesn't say whether it treats a `200` for a new request as success.

**Workarounds:**

* If you receive Cyera's requests directly, return `201` only after the request is stored durably, and a 4xx or 5xx if it isn't.
* If you put a queue or gateway in front, confirm with Cyera that a `200` is accepted for a new request before you rely on it.

**How Hookdeck Can Help:** Hookdeck answers Cyera with a `200` as soon as it has stored the request, so Cyera sees that rather than your service's `201` or error. In exchange, no request is lost while your service is down. Confirm with Cyera that it accepts the `200` for new requests, and treat the callback as the place where you report failures, for example with `status: "error"` on an opt-out.

### The real outcome is a separate call

**The Problem:** Requests stay open in Cyera even though your system did the work.

**Why It Happens:** Completion is reported by your `PUT` to `webhook_callback_url`, not by the webhook response. A missing API key scope, a malformed body or a `409` because the request was already processed all leave the request unresolved.

**Workarounds:**

* Store `webhook_callback_url`, `webhook_request_id` and the request ID with the job, and retry the callback on 5xx responses.
* Treat a `409` as done, since Cyera uses it for requests already processed.

**How Hookdeck Can Help:** Hookdeck keeps every request with its full body, so the callback URL and IDs for any request are there to look up and replay if your job runner lost them.

### Reminders arrive for incomplete requests

**The Problem:** The same privacy request appears more than once.

**Why It Happens:** Cyera sends `privacy_request:reminder` and `opt_out:reminder` for requests that are still incomplete, with the same `privacy_request_id` or `opt_out_request_id`.

**Workarounds:**

* Key your jobs on `privacy_request_id` or `opt_out_request_id`, and treat a reminder as a prompt to check that job rather than to start a new one.
* Answer reminders with `200`, as Cyera asks.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can match on `topic`, so reminders can go to a monitoring destination while new requests go to the job runner.

### Delivery behaviour is undocumented

**The Problem:** You don't know whether Cyera retries a request your endpoint missed.

**Why It Happens:** Cyera documents no retries, timeout or source IPs. Periodic health checks show whether your endpoint is up, but not what happens to a request sent during an outage.

**Workarounds:**

* Keep the endpoint highly available and acknowledge quickly.
* Watch the open requests in Cyera for any your systems never received.

**How Hookdeck Can Help:** Hookdeck accepts each request as it arrives and stores it, then delivers to your service with [retries](/docs/retries) you configure, so an outage on your side doesn't depend on Cyera retrying.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Cyera sent. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `Buffer.from(await request.arrayBuffer())`. In FastAPI, use `await request.body()`.

### Route on `request_type`, then `topic`

Answer `health_check` straight away. Start work only for `:create` topics, and use reminders to check on work already under way.

### Store the request before you answer

Personal data requests have legal deadlines. Write the request, including `webhook_callback_url`, to durable storage before you respond, so nothing depends on an in-memory job.

### Make handlers idempotent

Deduplicate on `webhook_request_id` for deliveries, and on `privacy_request_id` or `opt_out_request_id` for the work. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) and [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Cyera Privacy webhooks dispatch access, deletion and opt-out requests to your systems and expect a callback when the work is done. Verify `X-Webhook-Signature` as a base64 HMAC-SHA256 of the timestamp, a colon and the raw body, route on `request_type` and `topic`, and remember that the status code you return tells Cyera whether the request was created.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Cyera signature, and holds each privacy request until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Cyera webhooks reliably in minutes.