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 paidorder.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.)
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')
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.
payload example for events order.paid
order.canceled
order.transferred
order.call
{
"meta": {
"id": "715ce26b-8e7d-4796-80d7-bb92970d726d",
"event": "order.paid",
"emittedAt": "2024-10-10T14:59:56.268Z"
},
"data": {
"id": "65825B5F-F7DC-4BCF-9EDB-25C9751377AB",
"locationId": "7268c3d1-5e8d-46b9-90ba-318c495df867",
"establishmentName": "Jean de la Côte",
"address1": "3 rue de la Côte",
"address2": null,
"customHeader": "Une expérience culinaire authentique avec une touche moderne",
"siretCode": "987 654 321 09876",
"phoneNumber": "05 56 20 30 41",
"zipCode": "33300",
"city": "Bordeaux",
"country": "France",
"website": "www.jdc.fr",
"nafCode": "5610A",
"tvaCode": "FR89350753125",
"customFooter": null,
"companyName": null,
"roomName": "Salle",
"roomType": "room",
"tableName": "8",
"isPaid": true,
"isCanceled": false,
"isTransferred": false,
"total": 4730,
"totalTax": 5,
"totalDiscount": 480,
"totalWithoutTax": 4725,
"paidAt": "2024-10-10T14:59:53.000Z",
"deviceIdentifier": "c92c45e0-ab19-4176-a39c-54546e7cf024",
"channel": "unknown",
"serviceType": "unknown",
"productRowList": [
{
"id": "A979B60D-FF95-4ECE-A7F0-2760F0B80C9A",
"name": "Schweppes",
"isCanceled": false,
"quantity": 1,
"weight": null,
"transmittedAt": "2024-10-10T14:58:43.000Z",
"reclaimedAt": "2024-10-10T14:58:43.000Z",
"preparationSequence": 0,
"currencyCode": "EUR",
"productCatalogId": "5c1200e1-2422-4117-bd79-6d2c61887f88",
"taxRate": 10,
"taxAmount": 0,
"taxableAmount": 380,
"unitPriceRow": 380,
"unitPriceRowWithModifier": 380,
"totalDiscount": 0,
"totalRow": 380,
"totalRowWithModifier": 380,
"modifierRowList": []
},
{
"id": "305A22C3-4A31-41E8-B233-E6B01B2E541A",
"name": "Café",
"isCanceled": false,
"quantity": 1,
"weight": null,
"transmittedAt": "2024-10-10T14:58:43.000Z",
"reclaimedAt": "2024-10-10T14:58:43.000Z",
"preparationSequence": 0,
"currencyCode": "EUR",
"productCatalogId": "e6568fc6-305b-4a18-a6a0-4bc249799465",
"taxRate": 10,
"taxAmount": 0,
"taxableAmount": 0,
"unitPriceRow": 160,
"unitPriceRowWithModifier": 160,
"totalDiscount": 160,
"totalRow": 0,
"totalRowWithModifier": 160,
"modifierRowList": []
},
{
"id": "2A3A1267-0043-4CEB-896D-90C1EEFDA20F",
"name": "Salade landaise",
"isCanceled": false,
"quantity": 1,
"weight": null,
"transmittedAt": "2024-10-10T14:58:43.000Z",
"reclaimedAt": "2024-10-10T14:58:43.000Z",
"preparationSequence": 0,
"currencyCode": "EUR",
"productCatalogId": "81784041-7df2-4c41-b43f-13bd57be3f39",
"taxRate": 10,
"taxAmount": 1,
"taxableAmount": 799,
"unitPriceRow": 800,
"unitPriceRowWithModifier": 800,
"totalDiscount": 0,
"totalRow": 800,
"totalRowWithModifier": 800,
"modifierRowList": []
},
{
"id": "BCEEC3F5-26BF-4B31-9B9A-6DEDC8C31227",
"name": "Supreme de poulet",
"isCanceled": false,
"quantity": 1,
"weight": null,
"transmittedAt": "2024-10-10T14:58:43.000Z",
"reclaimedAt": "2024-10-10T14:58:47.000Z",
"preparationSequence": 1,
"currencyCode": "EUR",
"productCatalogId": "d59fce91-0f4e-466c-9b7f-6359207b4b14",
"taxRate": 10,
"taxAmount": 1,
"taxableAmount": 1199,
"unitPriceRow": 1200,
"unitPriceRowWithModifier": 1200,
"totalDiscount": 0,
"totalRow": 1200,
"totalRowWithModifier": 1200,
"modifierRowList": []
},
{
"id": "26076C1A-FD7E-4430-9ECF-CAA4EFDC3E63",
"name": "Perrier",
"isCanceled": false,
"quantity": 1,
"weight": null,
"transmittedAt": "2024-10-10T14:58:43.000Z",
"reclaimedAt": "2024-10-10T14:58:43.000Z",
"preparationSequence": 0,
"currencyCode": "EUR",
"productCatalogId": "fac7de3a-2ce6-4078-a6e6-fd7ca9f61911",
"taxRate": 10,
"taxAmount": 0,
"taxableAmount": 350,
"unitPriceRow": 350,
"unitPriceRowWithModifier": 350,
"totalDiscount": 0,
"totalRow": 350,
"totalRowWithModifier": 350,
"modifierRowList": []
},
{
"id": "67358A06-807A-46D3-81E1-5EE8E69874BC",
"name": "Grand café",
"isCanceled": false,
"quantity": 1,
"weight": null,
"transmittedAt": "2024-10-10T14:58:43.000Z",
"reclaimedAt": "2024-10-10T14:58:43.000Z",
"preparationSequence": 0,
"currencyCode": "EUR",
"productCatalogId": "ee508a36-5aeb-4eed-a483-255fcf5ce437",
"taxRate": 10,
"taxAmount": 0,
"taxableAmount": 0,
"unitPriceRow": 320,
"unitPriceRowWithModifier": 320,
"totalDiscount": 320,
"totalRow": 0,
"totalRowWithModifier": 320,
"modifierRowList": []
}
],
"menuRowList": [
{
"unitPriceRow": 2000,
"unitPriceRowWithModifier": 2000,
"totalRowWithModifier": 2000,
"totalDiscount": 0,
"totalRow": 2000,
"currencyCode": "EUR",
"name": "Menu complet",
"productRowList": [
{
"id": "53E59D77-665C-447C-8544-9FEDC330057D",
"name": "Entree du jour",
"isCanceled": false,
"quantity": 1,
"weight": null,
"transmittedAt": "2024-10-10T14:58:43.000Z",
"reclaimedAt": "2024-10-10T14:58:43.000Z",
"preparationSequence": 0,
"currencyCode": "EUR",
"productCatalogId": "d4d07604-e1fb-4053-b610-3aec4a72c851",
"taxRate": 10,
"taxAmount": 1,
"taxableAmount": 713,
"unitPriceRow": 714,
"unitPriceRowWithModifier": 714,
"totalDiscount": 0,
"totalRow": 714,
"totalRowWithModifier": 714,
"modifierRowList": []
},
{
"id": "0586FEE6-7AC9-4364-A24E-E962B84ED0C0",
"name": "Plat du jour",
"isCanceled": false,
"quantity": 1,
"weight": null,
"transmittedAt": "2024-10-10T14:58:43.000Z",
"reclaimedAt": "2024-10-10T14:58:47.000Z",
"preparationSequence": 1,
"currencyCode": "EUR",
"productCatalogId": "0b068189-a01f-4b6b-a2ea-dd09fb3936ae",
"taxRate": 10,
"taxAmount": 1,
"taxableAmount": 856,
"unitPriceRow": 857,
"unitPriceRowWithModifier": 857,
"totalDiscount": 0,
"totalRow": 857,
"totalRowWithModifier": 857,
"modifierRowList": [{ "name": "Bleu", "amount": 0 }]
},
{
"id": "7A4E9744-9CF6-4EEF-8D56-160826D549BD",
"name": "Dessert du jour",
"isCanceled": false,
"quantity": 1,
"weight": null,
"transmittedAt": "2024-10-10T14:58:43.000Z",
"reclaimedAt": "2024-10-10T14:58:51.000Z",
"preparationSequence": 2,
"currencyCode": "EUR",
"productCatalogId": "280a3655-7bc9-4e29-8fc7-482da676b387",
"taxRate": 10,
"taxAmount": 0,
"taxableAmount": 429,
"unitPriceRow": 429,
"unitPriceRowWithModifier": 429,
"totalDiscount": 0,
"totalRow": 429,
"totalRowWithModifier": 429,
"modifierRowList": []
}
]
}
],
"paymentRowList": [
{
"name": "Carte de crédit",
"status": "validated",
"amount": 3000,
"changeAmount": 0,
"changeName": "Espèces",
"tipAmount": 0
},
{
"name": "Titre restaurant",
"status": "validated",
"amount": 900,
"changeAmount": 0,
"changeName": "Espèces",
"tipAmount": 0
},
{
"name": "Espèces",
"status": "validated",
"amount": 1500,
"changeAmount": -670,
"changeName": "Espèces",
"tipAmount": 670
}
]
}
}
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.