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)
Reference¶
For all webhook types the payload will be a json object with the following top-level structure:
{
"id": "[A-Za-z0-9]{64}",
"type": "room_stay.updated",
"created_at": 2147483647,
"webhook_endpoint_id": "[A-Za-z0-9]{64}",
"data": ...
}
Field | Type | Description |
---|---|---|
id |
string |
Unique id of this event. Multiple endpoints receiving the same event will have the same id. |
type |
string |
name of the webhook |
created_at |
int |
timestamp of when the event happened. May not be the same as when your integration receives this. |
webhook_endpoint_id |
string |
id of the webhook endpoint that was created via graphql api.Can be used to identify what hotel this webhook belongs to. |
data |
any |
Main body of the webhook. See specific type’s reference for more details. |
Best practice
For forwards compatibility, allow unknown fields at any nesting level in the payload
If you want to get notifications about any additional event types not listed here, contact 3RPMS support at office@3rpms.de describing your use case.
reservation.created
¶
Sent when a new Reservation
is created.
Body shape
"object": {
"id": "[A-Za-z0-9-]{1,64}"
}
Field | Type | Description |
---|---|---|
object.id |
string |
Associated reservation’s id. Use this to query for more data from the API |
reservation.updated
¶
Sent when an existing Reservation
is updated.
Body shape
"object": {
"id": "[A-Za-z0-9-]{1,64}"
},
"updated_fields": [...]
Field | Type | Description |
---|---|---|
object.id |
string |
Associated reservation’s id. Use this to query for more data from the API |
updated_fields |
string[]|null |
list of Reservation field names (from graphql API) that have changed. When updated_fields is not present, assume that any field could have changed. * This will not include *Connection type fields. * Fields that contain their own ID (for example, client or contact ) will only be included when a different record is referenced, for example, a change to client’s first name will not trigger a reservation.updated webhook (use client.updated event for that), but changing reservation’s referenced client to someone else will trigger a reservation.updated webhook. |
room_stay.created
¶
Sent when a new RoomStay
is created.
Body shape
"object": {
"id": "[A-Za-z0-9-]{1,64}"
}
Field | Type | Description |
---|---|---|
object.id |
string |
Associated room stay’s id. Use this to query for more data from the API |
updated_fields |
string[]|null |
list of RoomStay field names (from graphql API) that have changed. When updated_fields is not present, assume that any field could have changed. This will not include *Connection type fields. |
room_stay.updated
¶
Sent when an existing RoomStay
is updated.
Body shape
"object": {
"id": "[A-Za-z0-9-]{1,64}"
},
"updated_fields": [...]
Field | Type | Description |
---|---|---|
object.id |
string |
Associated room stay’s id. Use this to query for more data from the API |
updated_fields |
string[]|null |
list of RoomStay field names (from graphql API) that have changed. When updated_fields is not present, assume that any field could have changed. * This will not include *Connection type fields. * Fields that contain their own ID (for example, reservation or guests ) will only be included when a different record is referenced; for example, a change to guest’s first name will not trigger a room_stay.updated webhook (use client.updated event for that), but changing adding a new guest will trigger a room_stay.updated webhook. |
room_stay.deleted
¶
Sent when an existing RoomStay
is deleted.
Body shape
"object": {
"id": "[A-Za-z0-9-]{1,64}"
}
Field | Type | Description |
---|---|---|
object.id |
string |
Associated room stay’s id. |
client.updated
¶
Sent when an existing Client
is updated.
Body shape
"object": {
"id": "[A-Za-z0-9-]{1,64}"
}
Field | Type | Description |
---|---|---|
object.id |
string |
Associated client’s id. Use this to query for more data from the API |
client.deleted
¶
Sent when an existing Client
is deleted.
Body shape
"object": {
"id": "[A-Za-z0-9-]{1,64}"
}
Field | Type | Description |
---|---|---|
object.id |
string |
Associated client’s id |
room_access_key.deleted
¶
Sent when an existing RoomAccessKey
is deleted.
Body shape
"object": {
"id": "[A-Za-z0-9-]{1,64}"
}
Field | Type | Description |
---|---|---|
object.id |
string |
Associated room access key’s id |
category.availability.updated
¶
Sent when room availability for a Category
changes.
Body shape
"object": {
"id": "[A-Za-z0-9-]{1,64}"
},
"period": {
"start": "YYYY-MM-DD",
"end": "YYYY-MM-DD"
}
Field | Type | Description |
---|---|---|
object.id |
string |
Associated category’s id |
period |
object|null |
Affected period. Will be null when all dates are affected. |
period.start |
date-string |
Start (inclusive) of the affected period. |
period.end |
date-string |
End (inclusive) of the affected period. |
Creating a webhook¶
- Identify the webhooks to receive
- Handle requests from 3RPMS by parsing each event object and returning 2xx response status codes.
- 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:
room_stay.created
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
Additional Data¶
A webhook notifies your integration that something happened and includes only the bare minimum data to identify the affected record, 3RPMS will not know what fields your integrations needs.
After receiving a webhook, its expected that your integration will query the API for the data it needs. For example, for a room_stay.updated
webhook, can use the data.object.id
value to query for the room’s current stay period:
# 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
# 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