3RPMS uses webhooks to notify your application when an event happens in a hotel. Webhooks enable 3RPMS to push real-time notifications to your integration. Webhooks are sent as a HTTPS POST request with a JSON payload. You can then use these notifications to execute actions in your integration.

Terminology

  • Webhook - an event notification
  • Webhook endpoint - your server that receives event notifications (webhooks)

Supported events

Currently supported event types. Names should be self-explanatory:

  • reservation.created
  • reservation.updated
  • room_stay.created
  • room_stay.updated
  • room_stay.deleted
  • client.updated
  • client.deleted

If you want to get notifications about any additional event types, contact 3RPMS support at office@3rpms.de describing your use case. New event types can be added as needed.

Creating a webhook

  1. Identify the webhooks to receive
  2. Handle requests from 3RPMS by parsing each event object and returning 2xx response status codes.
  3. Register your endpoint with 3rpms

Webhook endpoint limit

There is a limit of 5 active webhook endpoints per hotel per integration

Step 1: Identify the webhooks to receive

See Supported events section. Each webhook endpoint can listen to multiple events.

Best practice

Create endpoints with only events you need for better performance.

Step 2: Handle requests from 3RPMS

Webhooks will be sent as a POST request, with payload in the request’s body

const express = require('express');
const crypto = require('crypto');
const scmp = require('scmp');
const app = express();
// If the same endpoint is used for multiple hotels, each one will have a different endpoint secret
const webhookEndpointSecret = '[your_webhook_endpoint_secret]';
app.post('/3rpms', express.raw({type: 'application/json'}), (request, response) => {
    const payloadRaw = request.body;
    const signatureHeader = request.header('3rpms-signature');
    const payload = JSON.parse(payloadRaw);
    if (!verifyHeader(signatureHeader, payloadRaw, webhookEndpointSecret)) {
        // See Security section below for `verifySignature` definition
        response.sendStatus(400);
        return;
    }
    if (payload.type === 'room_stay.created') {
        const roomStayId = payload.data.object.id;
        // Execute your actions for `room_stay.created`
    } else if (payload.type === 'room_stay.updated') {
        const roomStayId = payload.data.object.id;
        // Execute your actions for `room_stay.updated`
        //} else if ... { }
    } else {
        console.log(`Unhandled event type ${payload.type}`);
    }
    // Return a response to acknowledge receipt of the event
    response.sendStatus(204);
});
app.listen(8000, () => console.log('Running on port 8000'));
$payloadRaw = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_3RPMS_SIGNATURE'];
if (!verifySignature($signatureHeader, $payloadRaw, '[your_webhook_endpoint_secret]')) {
    // See Security section below for `verifySignature` definition
    // If the same endpoint is used for multiple hotels, each one will have a different endpoint secret
    http_response_code(400);
    exit();
}
$payload = json_decode($payloadRaw, true, 4, JSON_THROW_ON_ERROR);
if ($payload['type'] === 'room_stay.created') {
    $roomStayId = $payload['data']['object']['id'];
    // Execute your actions for `room_stay.created`
} elseif ($payload['type'] === 'room_stay.updated') {
    $roomStayId = $payload['data']['object']['id'];
    // Execute your actions for `room_stay.updated`
//} eseif (...)
} else {
    echo 'Received unknown event type ' . $payload['type'];
}
http_response_code(200);

Secure connections

All endpoints are required to use https with a validate certificate

Duplicate events

Webhook endpoints might occasionally receive the same webhook more than once. We advise you to guard against duplicated webhooks by making your webhook processing idempotent. One way of doing this is logging the webhooks you’ve processed, and then not processing already-logged events.

3RPMS guarantees at least once delivery for webhooks

Order of events

3RPMS does not guarantee delivery of webhooks in the order in which they are generated. For example, creating a room stay and immediately deleting it might generate the following webhooks:

  1. room_stay.created
  2. room_stay.deleted

It’s possible you’ll receive a roomstay.deleted webhook first, for a room stay your integration doesn’t know about. Your endpoint must not expect delivery of these webhooks in this order and should handle this accordingly. Use the API to fetch any missing objects (for example, you can fetch the reservation when receiving room_stay.created webhook).

Security

3RPMS signs all webhooks by including a signature in each request’s 3rpms-signature header. This allows you to verify that the webhook was sent by 3RPMS, not by a third party.

To verify signatures, you’ll need to save your endpoint’s secret when registering your webhooks endpoint in step 3. It can be retrieved only once when first registering the endpoint.

3RPMS generates a unique secret key for each endpoint. Additionally, if you use multiple endpoints, you must obtain a secret for each one you want to verify signatures on.

Preventing replay attacks

A replay attack is when an attacker intercepts a valid payload and its signature, then re-transmits them. To mitigate such attacks, 3RPMS includes a timestamp in the 3rpms-signature header. Because this timestamp is part of the signed payload, it is also verified by the signature, so an attacker cannot change the timestamp without invalidating the signature. If the signature is valid but the timestamp is too old, you can have your application reject the payload. We recommend a tolerance of no more than five minutes between the timestamp and the current time.

3RPMS generates the timestamp and signature each time we send a webhook to your endpoint. If 3RPMS retries an webhook (for example, your endpoint previously replied with a non-2xx status code), then we generate a new signature and timestamp for the next delivery attempt.

Verify signature

The 3rpms-signature header included in each signed webhook contains a timestamp and one or more signatures. The timestamp is prefixed by t=, and each signature is prefixed by a signature=.

3rpms-signature:
t=1658997155,
signature=e889ddfe87e55422a1a6493b2db846548ff2a7f22268501b519f9f4f5f70e2ff

3RPMS generates signatures using a hash-based message authentication code (HMAC) with SHA-256. For forwards compatibility, your integration must support handling multiple signatures prefixed with signature=.

Step 1: Extract the timestamp and signatures from the header

Split the header, using the , character as the separator, to get a list of elements. Then split each element, using the = character as the separator, to get a (prefix, value) pair.

The value for the prefix t corresponds to the timestamp, and signature corresponds to the signature (or signatures). You can discard all other elements.

Step 2: Prepare the signed payload string

The signed_payload string is created by concatenating:

  • The timestamp (as a string) from header
  • The character .
  • The full request POST body
Step 3: Determine the expected signature

Compute an HMAC with the SHA256 hash function. Use the endpoint’s signing secret as the key, and use the signed_payload string as the message.

Step 4: Compare the signatures

Compare the signature (or signatures) in the header to the expected signature. For an equality match, compute the difference between the current timestamp and the received timestamp, then decide if the difference is within your tolerance.

To protect against timing attacks, use a constant-time string comparison to compare the expected signature to each of the received signatures.

Sample code
function verifyHeader(header, body, secret, tolerance = 5 * 60) {
    // Header contains two parts, timestamp and signature, in format `t=123,signature=abc`
    const {timestamp, signatures} = header.split(',').reduce((acc, pair) => {
        const [key, value] = pair.split('=');
        if (key === 't') acc.timestamp = value;
        else if (key === 'signature') acc.signatures.push(value);
        return acc;
    }, {
        timestamp: -1,
        // For forwards compatibility, allow multiple `signatures` to be present `signature=agfdgfdgd,signature=gkdsgde`
        signatures: [],
    });
    if (timestamp === -1) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > tolerance) {
        // Reject to prevent replay attacks.
        return false;
    }
    const signedPayload = timestamp + '.' + body;
    const expectedSignature = crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex');
    for(const signature of signatures) {
        if (scmp(Buffer.from(expectedSignature), Buffer.from(signature))) {
            return true;
        }
    }
    return false;
}
function verifySignature(string $header, string $payload, string $secret, int $tolerance = 5 * 60): bool
{
    // Header contains two parts, timestamp and signatures, in format `t=123,signature=abc,signature=def`
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $item) {
        [$key, $value] = explode('=', $item, 2);
        if ($key === 't') {
            $timestamp = $value;
        } elseif ($key === 'signature') {
            $signatures[] = $value;
        }
    }
    if (abs(time() - $timestamp) > $tolerance) {
        // Difference between current time and sent time is too high.
        // Reject to prevent replay attacks.
        return false;
    }
    $signedPayload = $timestamp . '.' . $payload;
    $expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
    // For forwards compatibility, support multiple `signatures` in the header. Any succeeding counts as verified
    foreach ($signatures as $signature) {
        if (hash_equals($expectedSignature, $signature)) {
            return true;
        }
    }
    return false;
}

Timeouts

Webhooks that take too long to deliver a response status code will be treated as failed and delivery will be attempted again.

Step 3: Register your endpoint with 3rpms

See graphQL docs on how to set use our api

# Variables
# {
#   "input": {
#     "url":"https://example.com/3rpms-webhook",
#     "status":"DISABLED",
#     "events":["room_stay.created"]
#   }
# }
mutation CreateWebhookEndpoint($input:CreateWebhookEndpointInput!) {
  createWebhookEndpoint(input:$input) {
    webhookEndpoint { id }
    secret
  }
}

Make sure to save response’s webhookEndpoint.id as it may be needed to identify the hotel when receiving a webhook.

Best practice

Create new webhook endpoints in a DISABLED state, and update it to ENABLED after persisting the signing secret. This will avoid signing errors for webhooks immediately after registering the webhook endpoint

Payload structure

The payload will always be a json object with the following structure:

{
  "id": "[A-Za-z0-9]{64}",
  "type": "room_stay.updated",
  "created_at": 2147483647,
  "webhook_endpoint_id": "[A-Za-z0-9]{64}",
  "data": {
    "object": {
      "id": "[A-Za-z0-9-]{1,64}"
    }
  }
}
  • id - unique id of this event. Multiple endpoints receiving the same event will have the same id
  • type - See Supported events section
  • created_at - timestamp of when the event happened. May not be the same as timestamp your server receives this.
  • webhook_endpoint_id - Same as webhookEndpoint.id when creating a webhook endpoint via graphql api. Can be used to identify what hotel this webhook belongs to.
  • data - For all types data is the same at the moment.
    • data.object.id is the associated event’s object id. It can be used to query data you need from api.

Best practice

For forwards compatibility, allow unknown fields at any nesting level in the payload

Retrieve more data

To retrieve more data than is present in the webhook, use the data.object.id value with graphql api. For example, to retrieve a room stay:

# Variables
# {
#   "id": "<value from data.object.id>"
# }
query RoomStay($id:ID!) {
  room_stays(filter:{id:{eq:$id}} first:1) {
    edges {
      node {
        id
        arrival:reservation_from
        departure:reservation_to
        # whatever other fields needed
      }
    }
  }
}

Retries

3RPMS webhooks have built-in retry methods for 3xx, 4xx, or 5xx response status codes. Any webhooks with a non-2xx respones status code will be attempted again for a few days or until a 2xx response status code is received

Redirects

Webhook endpoint redirects will not be followed for security reasons. 3xx response status code is treated as failing and will be re-sent.

Failing endpoints

Endpoints will be disabled when all sent webhooks have been failing for a few days