# Guide to Remote Webhooks: Features and Best Practices

Remote webhooks tell your systems what is happening with the people you employ through Remote: an employee finishes an onboarding task, a payslip is released, time off is requested or approved, an expense is submitted, or an offboarding completes. If you use Remote, the global employment and payroll platform, or build on its API as a partner, webhooks are how those changes reach your HR systems, finance tools and internal workflows without polling.

This guide covers how Remote webhook callbacks are registered and signed, the events you can subscribe to, Remote's unusual retry rules, and the practices that keep a Remote receiver reliable.

## What are Remote webhooks?

In Remote's API, a webhook subscription is a callback: a URL plus the events it should receive. Every callback belongs to a single company. There are no global or partner-level webhooks, so a partner serving several companies registers callbacks for each one.

Each delivery is a JSON `POST` signed with the callback's `signing_key`. `X-Remote-Timestamp` carries the send time in Unix milliseconds, and `X-Remote-Signature` carries a hex HMAC-SHA256 of the raw body, a colon and that timestamp. The order matters: the body comes first and the timestamp last, the reverse of most schemes.

Remote's retry rules are the other thing to know up front. Remote retries only when it can't reach your endpoint at all. Any 4xx or 5xx response counts as delivered.

## Remote webhook features

| Feature | Details |
| --- | --- |
| Configuration | API: `POST /v1/webhook-callbacks` with a company-scoped token, or the Remote CLI |
| Scope | One company per callback. Each callback URL must be unique |
| Subscription model | `subscribed_events` (a list) or `subscribe_to_all_events: true` |
| Signature header | `X-Remote-Signature`, lowercase hex |
| Timestamp header | `X-Remote-Timestamp`, Unix milliseconds |
| Scheme | HMAC-SHA256, hex |
| Signed content | `<raw body>:<X-Remote-Timestamp>` |
| Secret | `signing_key`, unique per callback and returned only at creation |
| Delivery | At-least-once |
| Retries | Transport failures only, at 30 seconds, 5 minutes, 1 hour, 6 hours and 18 hours |
| 4xx and 5xx | Treated as delivered, never retried |
| Recovery | `GET /v1/webhook-events` and `POST /v1/webhook-events/replay` |
| Testing | A Sandbox-only trigger endpoint |
| Replay window, source IPs | Not documented |

## Common events

Remote's catalogue lists over 100 events, each with a prerequisite and a trigger condition. They fall into these families:

| Family | Example events | Fires when |
| --- | --- | --- |
| Employment and onboarding | `employment.onboarding.started`, `employment.onboarding_task.completed`, `employment.onboarding.completed`, `employment.start_date.changed` | An employee's onboarding starts, a task or the whole onboarding completes, or employment details change |
| Company | `company.activated`, `company.manager_created`, `company.eor_hiring.verification_completed` | A company or its managers change, or EOR hiring checks progress |
| Contracts | `contract_amendment.submitted`, `contract_amendment.done`, `contract.termination_date_reached` | A contract change is submitted, reviewed or approved, or a contract ends |
| Contractors | `contractor_invoice.issued`, `contractor_invoice.employer_paid`, `contractor_invoice.paid_out` | A contractor invoice moves through its lifecycle |
| Time off | `timeoff.requested`, `timeoff.approved`, `timeoff.declined`, `timeoff.taken` | Time off is requested, decided, changed or taken |
| Expenses | `expense.submitted`, `expense.approved`, `expense.reimbursed` | An expense is submitted, decided or reimbursed |
| Payroll | `payslip.released`, `incentive.paid`, `billing_document.issued` | A payslip is released, an incentive is paid, or a billing document is issued |
| Offboarding | `offboarding.submitted`, `offboarding.submitted_to_payroll`, `offboarding.completed` | An offboarding request moves through review and payroll |

Each event's payload is documented on its own API reference page. Bodies are flat JSON objects with `event_type` and the IDs of the objects involved. Here is the body from Remote's verification example:

```json
{
  "company_id": "9e88cdac-4e57-46ca-a5a8-580150935cd8",
  "completed_task": {
    "action": "identity_verification",
    "completed_at": "2023-02-16T07:52:26Z",
    "description": "To help us keep you and our platform safe.",
    "name": "Verify your identity",
    "required": true,
    "status": "completed"
  },
  "employment_id": "b6e66f7c-9026-4afc-9f43-37bb31a8e509",
  "event_type": "employment.onboarding_task.completed"
}

```

Remote says to deduplicate "on the event id in the payload", but this example has no event ID field. Check the reference page for each event you subscribe to, and log a real delivery before you choose your deduplication key.

## Setting up Remote webhooks

1. Get a company-scoped access token, as a customer or a partner.
2. Register a callback with your endpoint URL and the events you want:

```bash
curl -X POST https://gateway.remote.com/v1/webhook-callbacks \
  -H "Authorization: Bearer $REMOTE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/remote",
    "subscribed_events": [
      "employment.onboarding_task.completed",
      "payslip.released"
    ]
  }'

```

1. Save the `id` and the `signing_key` from the response. Remote returns the signing key only at creation, so store it straight away, for example as `REMOTE_WEBHOOK_SECRET`.
2. In Sandbox, fire a test delivery with `POST https://gateway.remote-sandbox.com/v1/sandbox/webhook-callbacks/trigger`, passing an `employment_id` and an `event_type`. Register the production callback against `https://gateway.remote.com` once Sandbox deliveries verify.

`PATCH /v1/webhook-callbacks/{id}` changes the URL or events, and replaces the whole `subscribed_events` list, so include every event you want to keep. There is no rotation endpoint. If the signing key leaks, delete the callback and register the same URL again to get a new one.

If you receive Remote webhooks through Hookdeck, create a source with the Remote type, register its URL as the callback `url`, and paste the `signing_key` into the source. The source accepts `POST` only, which is the method Remote uses. Because each callback is scoped to one company and has its own key, a partner with several companies needs a source per callback.

## Securing Remote webhooks

Read `X-Remote-Timestamp` and `X-Remote-Signature`. Build the signed bytes from the raw request body, a literal `:`, and the timestamp exactly as received. Compute an HMAC-SHA256 of them with the `signing_key` as a UTF-8 key, hex-encode it, and compare it with the header in constant time.

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

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

  const signature = signatureHeader.trim();
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // check before Buffer.from(x, 'hex')

  // Optional replay window, off by default. X-Remote-Timestamp is in MILLISECONDS.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestampHeader)) return false;
    if (Math.abs(Date.now() - Number(timestampHeader)) > toleranceSeconds * 1000) return false;
  }

  // The raw body FIRST, then ":", then the timestamp.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(Buffer.concat([rawBody, Buffer.from(`:${timestampHeader}`, 'utf8')]))
    .digest();
  const received = Buffer.from(signature, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

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

  const valid = verifyRemoteSignature(
    req.body,
    req.get('x-remote-signature'),
    req.get('x-remote-timestamp'),
    secret
  );
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Queue the event and return 2xx. Remote never retries a 4xx or 5xx.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re
import time

def verify_remote_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
    signature = signature_header.strip()
    if not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
        return False
    # Optional replay window, off by default. X-Remote-Timestamp is in milliseconds.
    if tolerance_seconds > 0:
        if not (timestamp_header.isascii() and timestamp_header.isdigit()):
            return False
        if abs(time.time() * 1000 - int(timestamp_header)) > tolerance_seconds * 1000:
            return False
    signed = raw_body + b":" + timestamp_header.encode("utf-8")
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))

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

```

To check your verifier, use Remote's own example. The signing key is `wkyzvs764ifdrpct2naqhksmq4`, `X-Remote-Timestamp` is `1677816097219`, and the raw body is exactly this single line of 376 bytes:

```
{"company_id":"9e88cdac-4e57-46ca-a5a8-580150935cd8","completed_task":{"action":"identity_verification","completed_at":"2023-02-16T07:52:26Z","description":"To help us keep you and our platform safe.","name":"Verify your identity","required":true,"status":"completed"},"employment_id":"b6e66f7c-9026-4afc-9f43-37bb31a8e509","event_type":"employment.onboarding_task.completed"}

```

The signature should be:

```
e3f4092f158983aea32ab25f6fecc59f64b26d45fadbed6409893f3a882abef7

```

Use the compact body. The pretty-printed version Remote shows above it on the same page has different IDs and won't reproduce the signature. Remote suggests comparing the length of the body you read with `Content-Length` to confirm you have the raw bytes.

Remote's receiving guide says to "verify the signature and timestamp", but doesn't give a tolerance. The samples above leave that check off unless you pass a tolerance. Remote's own retries and replays carry a fresh timestamp and signature. Hookdeck's retries and replays don't: they resend Remote's original `X-Remote-Timestamp`, and Hookdeck applies no timestamp tolerance, so a short window in your own code would reject a legitimate Hookdeck retry.

> **Make Remote webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Remote-Signature` header over the raw body and timestamp, stores every employment, payroll and time off event, and delivers it to your service with retries you control.

## Remote webhook limitations and pain points

### An error response loses the event

**The Problem:** Your service returns a `500` during a bad deploy, and those events never come back.

**Why It Happens:** Remote treats any 4xx or 5xx as delivered and doesn't retry it. Only transport failures, such as a timeout or a connection, DNS or TLS error, are retried. Remote says plainly: "do not return an error status to ask Remote to send the event again".

**Workarounds:**

* Accept every verified delivery with a `2xx` and queue it on your side, as Remote advises.
* After an incident, list undelivered events with `GET /v1/webhook-events?successfully_delivered=false` and replay them. This needs an OAuth integration token with the `webhook.read` and `webhook.write` scopes. A Customer API token gets a `404` on these endpoints, so use the Remote CLI instead.

**How Hookdeck Can Help:** Hookdeck answers Remote with a `200` as soon as it has stored the request, so your service's errors never reach Remote. Hookdeck then delivers to your service with [retries](/docs/retries) you configure, and you can replay anything that failed. A request that fails signature verification is recorded as failed verification and doesn't reach your destination.

### The signed string is in an unusual order

**The Problem:** Verification code adapted from another provider never matches.

**Why It Happens:** Remote signs `body:timestamp`, with the body first and a colon. Most timestamped schemes sign `timestamp.body`. The timestamp is also in milliseconds.

**Workarounds:**

* Build the message as raw body bytes, then `:`, then the header value.
* Test against Remote's published example before you deploy.

**How Hookdeck Can Help:** Hookdeck's Remote source builds the signed bytes in Remote's order, so the scheme is handled in one place for every service behind it.

### The signing key is shown once and can't be rotated in place

**The Problem:** A new receiver needs the key and nobody saved it, or the key leaks.

**Why It Happens:** Remote returns `signing_key` only when the callback is created. The only way to change it is to delete the callback and register the URL again.

**Workarounds:**

* Store the key as part of the same script that registers the callback.
* Plan a rotation as delete and re-register, and expect a short gap in deliveries.

**How Hookdeck Can Help:** With Hookdeck in front, the key lives in the Remote source. After re-registering, update it there, without redeploying the services behind it.

### Every company needs its own callback

**The Problem:** A partner with many customer companies manages many callbacks and many keys.

**Why It Happens:** Each callback is scoped to one company, and there is no partner-level webhook.

**Workarounds:**

* Automate callback registration as part of connecting a new company.
* Keep a mapping from callback to company and signing key.

**How Hookdeck Can Help:** Hookdeck sources can all feed one destination, so each company's callback is verified with its own key while your service receives a single stream.

## Best practices

### Verify against the raw body

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

### Always return 2xx for verified deliveries

Since Remote never retries an error response, a 4xx or 5xx tells Remote nothing useful and loses the event. Store first, return `2xx`, then process.

### Deduplicate deliveries

Delivery is at-least-once. Remote says the timestamp is not a deduplication key, because each attempt gets a new one. Use the event ID where the payload carries one. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

A slow handler risks a timeout, and Remote treats that as a transport failure and retries it, producing a duplicate. Verify, persist, respond, then work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Remote signs every delivery with a hex HMAC-SHA256 of the raw body, a colon and the millisecond timestamp, keyed with a `signing_key` you see only once. Verify in that order, test against Remote's published example, and return `2xx` for every verified delivery, because Remote never retries a 4xx or 5xx.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Remote signature, and holds each event until your service has processed it, so a bad deploy doesn't cost you events Remote won't resend.

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