Skip to main content

Webhooks

This section will cover the webhooks API, the events available, the payloads and the security mechanisms.

Overview

Our API provides a webhooks API that allows recipients to subscribe to events that occur in our system. When one of these events is triggered, we send a HTTP POST payload to the webhook's configured URL.

Events

As recipient, you can subscribe to multiple events and receive a payload for each event that occurs.

Order events

Closing events
  • order.canceled - trigger when an order is canceled. It can occurs before or after the order was partially or fully paid
  • order.paid - triggered when an order is fully paid (and not canceled)
  • order.transferred - triggered when an order is transferred to an external service (must not be canceled)
Updating events
  • order.call - triggered when an order is updated. (open or split order, add update or cancel item | menu | payment | payment status | discount, send to preparation, move or group tables, etc.)
info

More events will be added soon.

Auth / Security

The webhooks endpoint url provided by the recipients must be a public endpoint with HTTPS protocol to secure communications between our API and the recipient.

HMAC-SHA256

As the recipient endpoint url must be public, anyone can send requests to it. Therefore, the recipient should check whether the request is coming from our API or not. To do so the recipient can use the x-popina-hmac-signature header that is sent with each request.

The value of this header corresponds to the sha256 of the request body using the client secret. The secret key is a 64 characters string provided by us, it is unique for each recipient and should be kept secret.

Computing the signature on its side, the recipient can compare it with the value of the x-popina-hmac-signature header to check if the request is coming from our API or not.

const signature = crypto
.createHmac('sha256', secretKey)
.update(payloadJsonString)
.digest('hex')
note

The HMAC signature is computed from the JSON string of the payload. Ensure that the JSON string is not indented and that the payload is not stringified twice, as this would cause a mismatch with the signature sent in the headers.

API key

The recipient can provide an API key to authenticate the requests. It is unique for each recipient and should be kept secret. If the recipient provides an API key, it is sent as a header x-api-key with each request.

Headers

Each webhook message has a variety of headers containing additional context.

{
"x-popina-webhook-event": "order.paid",
"x-popina-webhook-id": "f2909df-7b58-45dd-98a7-73f8f60e27c4",
"x-popina-hmac-signature": "3f2909df7b5845dd98a773f8f60e27c43f2909df7b5845dd98a773f8f60e27c4",
"x-api-key": "your-api-key"
}

Payloads

A webhook payload is composed of two parts: the meta and the data. The meta contains the information about the webhook itself (id, event, createdAt, payloadURL, payloadMethod) and the data contains an object with the event data.

{
"meta": {
"id": "f2909df-7b58-45dd-98a7-73f8f60e27c4",
"event": "order.paid",
"emittedAt": "2021-09-01T12:00:00.000Z"
},
"data": {
// event data, please check the data example below
}
}

payload example for events order.paid order.canceled order.transferred order.call

  {
"meta": {
"id": "02467445-1fa6-4d76-8c49-bb4712414237",
"event": "order.paid",
"emittedAt": "2024-06-13T10:14:25.629Z"
},
"data": {
"id": "4BABB977-A157-49D4-8651-85F12079EAA4",
"locationId": "a951ed11-3768-4817-bf6b-94eb7b550a1b",
"roomName": "Salle",
"tableName": "7",
"total": 5727,
"totalTax": 519,
"totalDiscount": 320,
"totalWithoutTax": 5208,
"paidAt": "2024-06-13T10:14:25.378Z",
"deviceIdentifier": "6f7f4aca-e908-4d19-854f-dae13501ad3a",
"productRowList": [
{
"id": "0170A4A8-3EE1-4C8B-9020-BADB4EA4FA3A",
"name": "Pelforth 25cl",
"isCanceled": false,
"quantity": 1,
"weight": null,
"currencyCode": "EUR",
"productCatalogId": "748ac934-44ee-4bc6-8c7c-a61834bb1d1e",
"taxRate": 2000, // 20%
"taxAmount": 58, // 0,58€ -> 20% of taxableAmount
"taxableAmount": 292,
"unitPriceRow": 350, // unit price
"unitPriceRowWithModifier": 350, // unit price with modifiers (no discount applied)
"totalDiscount": 0, // total discount
"totalRowWithModifier": 350, // total with modifiers -> unitPriceRowWithModifier * quantity
"totalRow": 350, // total with modifiers and discount -> totalRowWithModifier - totalDiscount
"modifierRowList": []
},
{
"id": "4D9E57CA-A708-4CA6-9967-53AC3BCCAA07",
"name": "Entrecote",
"isCanceled": false,
"quantity": 1,
"weight": null,
"currencyCode": "EUR",
"productCatalogId": "c36e7131-1c58-4214-918d-55143a352a19",
"taxRate": 1000,
"taxAmount": 145,
"taxableAmount": 1455,
"unitPriceRow": 1600,
"unitPriceRowWithModifier": 1600,
"totalDiscount": 0,
"totalRow": 1600,
"totalRowWithModifier": 1600,
"modifierRowList": [
{
"name": "Bleu",
"amount": 0
},
{
"name": "frites",
"amount": 0
},
{
"name": "poivre",
"amount": 0
}
]
},
{
"id": "4BEB8674-CFCB-4522-96E2-4F9E333FC0E4",
"name": "Bulots",
"isCanceled": false,
"quantity": 1,
"weight": 250, // in grams
"currencyCode": "EUR",
"productCatalogId": "A109D58E-BA99-4B1D-AA40-45D5BB983AAE",
"taxRate": 550,
"taxAmount": 38,
"taxableAmount": 687,
"unitPriceRow": 2900,
"unitPriceRowWithModifier": 2900,
"totalDiscount": 0,
"totalRow": 725,
"totalRowWithModifier": 725,
"modifierRowList": []
},
{
"id": "A3BF9D83-4883-4B9D-843F-F49AE56EEC37",
"name": "Café",
"isCanceled": false,
"quantity": 2,
"weight": null,
"currencyCode": "EUR",
"productCatalogId": "97273241-2a7a-43c0-a25f-3759dee90966",
"taxRate": 1000,
"taxAmount": 0,
"taxableAmount": 0,
"unitPriceRow": 160,
"unitPriceRowWithModifier": 160,
"totalDiscount": 320,
"totalRow": 0,
"totalRowWithModifier": 320,
"modifierRowList": []
},
{
"id": "78CB6721-2071-48D4-8EDF-9762F1D28F47",
"name": "Perrier",
"isCanceled": false,
"quantity": 1,
"weight": null,
"currencyCode": "EUR",
"productCatalogId": "4225afe6-0887-42d7-a0c3-5e5447272a51",
"taxRate": 1000,
"taxAmount": 32,
"taxableAmount": 318,
"unitPriceRow": 350,
"unitPriceRowWithModifier": 350,
"totalDiscount": 0,
"totalRow": 350,
"totalRowWithModifier": 350,
"modifierRowList": []
},
{
"id": "9224765B-98E9-4574-8E7B-F77B20EB3179",
"name": "Glace 1 boule",
"isCanceled": true, // canceled product
"quantity": 1,
"weight": null,
"currencyCode": "EUR",
"productCatalogId": "b783985a-e29c-4927-9c0d-69a5f609cf5f",
"taxRate": 1000,
"taxAmount": 50,
"taxableAmount": 500,
"unitPriceRow": 550,
"unitPriceRowWithModifier": 550,
"totalDiscount": 0,
"totalRow": 550,
"totalRowWithModifier": 550,
"modifierRowList": [
{
"name": "pistache",
"amount": 0
}
]
},
{
"id": "7154732D-6DE4-4A48-BA21-08CCCF290B1E",
"name": "Fondant chocolat",
"isCanceled": false,
"quantity": 1,
"weight": null,
"currencyCode": "EUR",
"productCatalogId": "cbb539f5-8671-4dbd-96f4-9549ff38aac9",
"taxRate": 1000,
"taxAmount": 64,
"taxableAmount": 636,
"unitPriceRow": 700,
"unitPriceRowWithModifier": 700,
"totalDiscount": 0,
"totalRow": 700,
"totalRowWithModifier": 700,
"modifierRowList": []
}
],
"menuRowList": [
{
"unitPriceRow": 2002,
"unitPriceRowWithModifier": 2002,
"totalRowWithModifier": 2002,
"totalDiscount": 0,
"totalRow": 2002,
"currencyCode": "EUR",
"name": "Menu complet",
"productRowList": [
// product list from the menu (not included in the productRowList above)
{
"id": "B1EC9B09-57A4-49FC-B577-F810053FE9B3",
"name": "Carpaccio boeuf",
"isCanceled": false,
"quantity": 1,
"weight": null,
"currencyCode": "EUR",
"productCatalogId": "98a933ad-9a37-4f45-907d-a18f78af2b46",
"taxRate": 1000,
"taxAmount": 53,
"taxableAmount": 528,
"unitPriceRow": 581,
"unitPriceRowWithModifier": 581,
"totalDiscount": 0,
"totalRow": 581,
"totalRowWithModifier": 581,
"modifierRowList": []
},
{
"id": "6EB41B45-7F57-42DB-86AB-12C9743ACE7B",
"name": "Supreme de poulet",
"isCanceled": false,
"quantity": 1,
"weight": null,
"currencyCode": "EUR",
"productCatalogId": "79cedee8-6196-4f60-acaa-28568164fd11",
"taxRate": 1000,
"taxAmount": 70,
"taxableAmount": 705,
"unitPriceRow": 775,
"unitPriceRowWithModifier": 775,
"totalDiscount": 0,
"totalRow": 775,
"totalRowWithModifier": 775,
"modifierRowList": []
},
{
"id": "E01CFB0A-EB70-4675-AEF9-166827EBED8B",
"name": "Cafe gourmand",
"isCanceled": false,
"quantity": 1,
"weight": null,
"currencyCode": "EUR",
"productCatalogId": "043a1e75-3405-4566-ab3d-125c596bf083",
"taxRate": 1000,
"taxAmount": 59,
"taxableAmount": 587,
"unitPriceRow": 646,
"unitPriceRowWithModifier": 646,
"totalDiscount": 0,
"totalRow": 646,
"totalRowWithModifier": 646,
"modifierRowList": []
}
]
}
],
"paymentRowList": [
{
"name": "Espèces",
"status": "validated", // pending | validated | cancelled
"amount": 2000,
"changeAmount": 0,
"changeName": null,
"tipAmount": 0
},
{
"name": "Titre restaurant",
"status": "validated",
"amount": 980,
"changeAmount": 0,
"changeName": null,
"tipAmount": 0
},
{
"name": "Carte de crédit",
"status": "cancelled",
"amount": 2000,
"changeAmount": 0,
"changeName": null,
"tipAmount": 0
},
{
"name": "Espèces",
"status": "validated",
"amount": 3000,
"changeAmount": -253,
"changeName": "Espèces",
"tipAmount": 0
}
]
}
}

Acknowledgement

The recipient must acknowledge the reception of the webhook by sending a 200 HTTP response within 30 seconds.

Rate limiting

The recipient endpoint may be rate-limited to prevent abuse. A 429 HTTP response will be handled as a acknowledgment failure and the webhook will be retried.

Error handling

If our API does not receive a 200 HTTP response within 30 seconds, the webhook will be retried up to 3 times with a backoff interval of 30 seconds.

If the webhook is still not acknowledged after 3 retries, it will be sent to a dead letter to our team and will not be retried anymore.

Versioning

We ensure backward compatibility for the webhooks API.