# Guide to Payroc Webhooks: Features and Best Practices

Payroc webhooks tell your platform when something changes on the merchant accounts you board with Payroc. When a processing account's status or risk status changes, when a merchant signs their processing agreement, or when a terminal order moves along, Payroc sends a notification to your endpoint, so your onboarding flow can move forward without polling the boarding API.

This guide covers how Payroc event subscriptions are created, the CloudEvents format and the events available, how to authenticate notifications with the `Payroc-Secret` header, what happens when delivery fails, and the practices that keep a Payroc receiver reliable.

## What are Payroc webhooks?

Payroc sends webhooks for **event subscriptions**. You create a subscription through the API with the event types you want, an endpoint URI, a secret and a support email address. When one of those events happens, Payroc sends a `POST` to the URI with a body in the [CloudEvents](https://cloudevents.io/) format.

Payroc doesn't sign notifications. The secret you set on the subscription is "a secret that we return in the header of webhook requests to verify that it is a genuine request": it arrives as plain text in the `Payroc-Secret` header, and your endpoint compares it with the value you stored.

Payroc's Hosted Payments Page has its own callback mechanism, which works differently and isn't covered here.

## Payroc webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only: `POST https://api.payroc.com/v1/event-subscriptions` |
| Method | `POST` |
| Format | CloudEvents 1.0, JSON |
| Event field | `type` |
| Authentication | Your secret, sent as plain text in `Payroc-Secret`. No signature or timestamp |
| Secret | Chosen by you, 16 to 64 characters. API responses mask it |
| Success | HTTP `200` |
| Retries | Five, then an email to the subscription's support address |
| Timeout | Not documented |
| Source IPs | Not published |

## Events and payloads

Payroc currently documents four events, all for boarding:

| `type` | Fires when |
| --- | --- |
| `processingAccount.status.changed` | Payroc changes the status of a processing account |
| `processingAccount.riskStatus.changed` | Payroc changes the risk status of a processing account |
| `processingAccount.signature.signed` | The Merchant Processing Agreement for a processing account is signed |
| `terminalOrder.status.changed` | Payroc changes the status of a terminal order |

Every notification uses the same CloudEvents attributes, with the event's details in `data`. Here is Payroc's example:

```json
{
  "specversion": "1.0",
  "type": "processingAccount.status.changed",
  "version": "1.0.0",
  "source": "payroc",
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "time": "2024-07-02T15:30:00.000Z",
  "datacontenttype": "application/json",
  "data": {
    "processingAccountId": "38765",
    "status": "entered"
  }
}

```

`version` is the version of the event type in Payroc's specification, not of CloudEvents itself (that's `specversion`). The events list gives `1.0.0` for every type, while the subscription guide's example shows `1.0`, so don't compare it as an exact string. `id` is unique per event and makes a good deduplication key.

## Setting up Payroc webhooks

Generate a secret between 16 and 64 characters long, for example with `openssl rand -hex 24`, which prints 48 characters. Then create the subscription with your API bearer token:

```bash
curl -X POST https://api.payroc.com/v1/event-subscriptions \
  -H "Authorization: Bearer $PAYROC_ACCESS_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "eventTypes": [
      "processingAccount.status.changed",
      "processingAccount.riskStatus.changed",
      "processingAccount.signature.signed",
      "terminalOrder.status.changed"
    ],
    "notifications": [
      {
        "type": "webhook",
        "uri": "https://example.com/webhooks/payroc",
        "secret": "<your secret>",
        "supportEmailAddress": "payments-team@example.com"
      }
    ]
  }'

```

The response returns the subscription `id`, which you use to retrieve, update or delete it, and shows the secret masked, so keep your own copy. The URI must be publicly reachable, and the support email address should be a monitored inbox, because that is where Payroc reports failed deliveries.

**With Hookdeck.** Hookdeck's Payroc source covers event subscriptions only, not Hosted Payments Page callbacks. Create a source with the **Payroc** type, paste the subscription's secret into **Secret**, and use the source URL as the notification `uri`. Hookdeck compares it with the `Payroc-Secret` header on each request. The source accepts `POST` only, which is what Payroc sends.

## Securing Payroc webhooks

Read `Payroc-Secret`, compare it with your stored secret in constant time, and only then parse the body.

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

const SECRET = process.env.PAYROC_WEBHOOK_SECRET;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/payroc', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('payroc-secret');
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Deduplicate on event.id, route on event.type, queue the work, then answer with exactly 200.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

SECRET = os.environ.get("PAYROC_WEBHOOK_SECRET", "")

def verify_payroc_request(headers) -> bool:
    if not SECRET:
        return False  # fail closed when unconfigured
    received = headers.get("Payroc-Secret")  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

```

A static secret authenticates the sender, not the body. It can't detect a modified payload, and with no signed timestamp a captured request can be replayed and still pass. Use HTTPS, a secret at the long end of the allowed range, and update the subscription and your receiver together if it might have leaked.

> **Make Payroc webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `Payroc-Secret` header on every notification, answers Payroc with a `200` straight away, and delivers each event to your service with retries you control.

## Payroc webhook limitations and pain points

### Five retries, then an email

**The Problem:** During an outage on your side, notifications that run out of retries end up as emails to your support address.

**Why It Happens:** Payroc needs a `200`. Without one, it retries five times and then emails the support address. Payroc doesn't document the interval between retries, and the email concerns that one notification: Payroc sends it "only when a single instance of an event notification fails to deliver".

**Workarounds:**

* Use a monitored support address, and treat those emails as a signal to reconcile against the boarding API.
* Answer with `200` as soon as the secret checks out, and process afterwards.

**How Hookdeck Can Help:** Hookdeck answers Payroc with a `200` once the notification is stored, then delivers it to your service with [retries](/docs/retries) on your own schedule. [Issues](/docs/issues) alert you when deliveries fail, and you can replay any stored event.

### Only `200` counts

**The Problem:** You return `202 Accepted` or `204 No Content`, and Payroc retries anyway.

**Why It Happens:** Payroc says "You must return a 200 status code" and retries "if we do not receive a 200 response". It doesn't mention other `2xx` codes.

**Workarounds:**

* Return exactly `200`.

**How Hookdeck Can Help:** Hookdeck answers Payroc with a `200`, whatever your service returns.

### API-only configuration

**The Problem:** There's no dashboard page to check which URL and events a subscription uses.

**Why It Happens:** Event subscriptions are created and changed through the API, and responses mask the secret.

**Workarounds:**

* Keep the subscription definition in code or configuration, alongside the secret in your secrets manager.
* Retrieve subscriptions by `id` to audit them.

**How Hookdeck Can Help:** If the subscription points at a Hookdeck source, the URI Payroc holds stays the same while you move or change the services behind it, so most changes don't need an API call to Payroc.

### A shared secret with no signature

**The Problem:** Anyone who learns the secret can send notifications that pass your check.

**Why It Happens:** `Payroc-Secret` carries the same value on every request and isn't tied to the body or a time.

**Workarounds:**

* Treat notifications as prompts and fetch the processing account or terminal order from the API before acting on anything important.
* Rotate the secret by updating the subscription, accepting both values in your receiver during the change.

**How Hookdeck Can Help:** Hookdeck's Payroc source compares `Payroc-Secret` with the secret you paste. Hookdeck answers Payroc with a `200` either way, then records a request without the right secret as failed verification, so it never reaches your destination.

## Best practices

### Route on `type`, deduplicate on `id`

Switch on the CloudEvents `type`, and store `id` before processing so a retried notification is skipped. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Fetch current state

`data` carries only a few fields, such as `processingAccountId` and `status`. Use them to fetch the full record from the Payroc API, which also protects you against a replayed request.

### Subscribe to every event you handle

Each subscription lists its event types. Add new types to the subscription as you build support for them, and acknowledge unexpected ones with `200`.

### Acknowledge fast

Return `200` immediately and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Payroc event subscriptions send CloudEvents JSON for processing account and terminal order changes, authenticated by the secret you choose in the `Payroc-Secret` header rather than a signature. Compare the header in constant time, route on `type`, deduplicate on `id`, and return exactly `200`, because five failed attempts end in an email instead of another delivery.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the secret, answers Payroc at once, and keeps every notification until your service has processed it.

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