Gareth Wilson Gareth Wilson

Guide to Remote Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI: POST /v1/webhook-callbacks with a company-scoped token, or the Remote CLI
ScopeOne company per callback. Each callback URL must be unique
Subscription modelsubscribed_events (a list) or subscribe_to_all_events: true
Signature headerX-Remote-Signature, lowercase hex
Timestamp headerX-Remote-Timestamp, Unix milliseconds
SchemeHMAC-SHA256, hex
Signed content<raw body>:<X-Remote-Timestamp>
Secretsigning_key, unique per callback and returned only at creation
DeliveryAt-least-once
RetriesTransport failures only, at 30 seconds, 5 minutes, 1 hour, 6 hours and 18 hours
4xx and 5xxTreated as delivered, never retried
RecoveryGET /v1/webhook-events and POST /v1/webhook-events/replay
TestingA Sandbox-only trigger endpoint
Replay window, source IPsNot documented

Common events

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

FamilyExample eventsFires when
Employment and onboardingemployment.onboarding.started, employment.onboarding_task.completed, employment.onboarding.completed, employment.start_date.changedAn employee's onboarding starts, a task or the whole onboarding completes, or employment details change
Companycompany.activated, company.manager_created, company.eor_hiring.verification_completedA company or its managers change, or EOR hiring checks progress
Contractscontract_amendment.submitted, contract_amendment.done, contract.termination_date_reachedA contract change is submitted, reviewed or approved, or a contract ends
Contractorscontractor_invoice.issued, contractor_invoice.employer_paid, contractor_invoice.paid_outA contractor invoice moves through its lifecycle
Time offtimeoff.requested, timeoff.approved, timeoff.declined, timeoff.takenTime off is requested, decided, changed or taken
Expensesexpense.submitted, expense.approved, expense.reimbursedAn expense is submitted, decided or reimbursed
Payrollpayslip.released, incentive.paid, billing_document.issuedA payslip is released, an incentive is paid, or a billing document is issued
Offboardingoffboarding.submitted, offboarding.submitted_to_payroll, offboarding.completedAn 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:

{
  "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:
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.

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:

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 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 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.

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.

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 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 for free and handle Remote webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.