Skip to main content

Reservations Webhook

Receive reservation status changes and cheque status changes from external partner integrations into the Sakneen system using the Reservations Webhook.

Overview

The Reservations Webhook is a single inbound endpoint that external partners (for example an OIC cheque-collection provider) call to push updates back into Sakneen. It supports two distinct update flows controlled by the request body:

  • Reservation status update — send reservationStatus to mark the reservation as approved or canceled.
  • Cheque status update — send installmentId together with chequeStatus (and optionally chequeNumber / chequeAmount) to update a single cheque inside the reservation's cheque wallet.

Updating a cheque can trigger downstream changes to the reservation and to the unit's availability — see Behavior for the full state machine.

Endpoint

POST /external/apis/v1.0/reservations/webhook

Authentication

All requests require a valid API key. The API key must be configured with allowExternal: true permission to access this endpoint.

Include your API key as the api-key request header:

api-key: YOUR_API_KEY

Alternatively, the API key may be supplied as a query parameter (?api-key=YOUR_API_KEY), but the header form is preferred so the key does not appear in URL logs.

The API key implicitly identifies your organization. Sending a reservationId that belongs to a different organization returns 404 Reservation not found rather than 403 — the reservation simply isn't visible to your key.

Required Headers

HeaderRequiredValue
api-keyYesYour API key (or pass as ?api-key= query parameter)
Content-TypeYesapplication/json

Request Body Schema

Required Fields

FieldTypeDescriptionValidation
reservationIdstringMongoDB ObjectId of the reservation to updateRequired, non-empty string

Optional Fields

FieldTypeDescriptionValidation
installmentIdstringIdentifier of the cheque/installment within the reservation's cheque wallet. Required when sending a cheque status update.Optional, non-empty string
chequeNumberstringCheque number to record alongside the cheque status updateOptional
chequeAmountnumberCheque amount to record alongside the cheque status updateOptional
chequeStatusenumNew status of the cheque. Must be sent together with installmentId.Optional, one of the values below
reservationStatusenumNew status of the reservation.Optional, one of the values below

Enum: chequeStatus

ValueMeaning
pendingCheque has been registered but no collection attempt has occurred yet
collectedCheque was successfully collected by the partner
overdueCheque is past its due date
bouncedCheque was returned/declined by the bank
post_datedCheque is dated for a future collection

Enum: reservationStatus

ValueMeaning
pendingReservation created, awaiting next step
approvedReservation finalized and the unit is reserved
pending_cheque_collectionAwaiting cheque collection by the partner
rejectedReservation declined
canceledReservation canceled
expiredReservation expired
info

The two update flows are independent and may be combined in a single request — if you send both reservationStatus and the pair installmentId + chequeStatus, the reservation update runs first and the cheque update runs after. Sending only reservationId is valid against the schema but produces no state change.

note

If the reservation's unit has no active cheque wallet and no soft-deleted wallet matching the request, the cheque-update branch is silently skipped — the response is still 201 and only any reservation-status update you sent will have been applied. Verify on your side that the wallet you expect to update exists before relying on this flow.

Behavior

The webhook applies different side-effects depending on which fields are sent and the current state of the reservation and cheque wallet.

Reservation status updates

Incoming reservationStatusRequired current stateEffect
approvedReservation must be pending_cheque_collectionApplies reservation-approval side-effects (unit status changes, downstream business logic) and sets the reservation to approved. Responds 422 if the reservation is in any other status.
canceledAnySets the reservation to canceled, attempts to soft-delete the cheque wallet (only takes effect if the reservation is OIC-active), and recomputes the unit's availability status.
Any other valueSchema-valid but ignored by the handler.

Cheque status updates

When installmentId + chequeStatus are sent, the matching cheque inside the reservation's cheque wallet is updated. chequeNumber and chequeAmount are stored if provided, and a status-history entry is appended.

TransitionEffect
Any → bouncedReservation is moved to canceled, the unit's status is recomputed, and the cheque wallet is soft-deleted (if OIC-active).
bouncedcollectedIf no other approved reservation exists for the same unit, the reservation is re-approved with full approval side-effects and the cheque wallet is restored (un-soft-deleted). Otherwise responds 422 This unit already reserved. Responds 404 Unit not found if the unit has been deleted.
Any non-bounced → collected, while reservation is pending_cheque_collectionReservation is moved to approved with full approval side-effects. Responds 404 Unit not found if the unit has been deleted.
All other transitionsCheque fields (status, optional number, optional amount) and status-history entry are persisted; no reservation/unit changes.

Request Examples

Approve a reservation

curl -X POST "https://your-domain/external/apis/v1.0/reservations/webhook" \
-H "api-key: your-api-key-here" \
-H "Content-Type: application/json" \
-d '{
"reservationId": "64f1234567890abcdef12345",
"reservationStatus": "approved"
}'

Cancel a reservation

curl -X POST "https://your-domain/external/apis/v1.0/reservations/webhook" \
-H "api-key: your-api-key-here" \
-H "Content-Type: application/json" \
-d '{
"reservationId": "64f1234567890abcdef12345",
"reservationStatus": "canceled"
}'

Mark a cheque as bounced

curl -X POST "https://your-domain/external/apis/v1.0/reservations/webhook" \
-H "api-key: your-api-key-here" \
-H "Content-Type: application/json" \
-d '{
"reservationId": "64f1234567890abcdef12345",
"installmentId": "INST-001",
"chequeStatus": "bounced",
"chequeNumber": "CHQ-998877",
"chequeAmount": 50000
}'

Mark a cheque as collected

curl -X POST "https://your-domain/external/apis/v1.0/reservations/webhook" \
-H "api-key: your-api-key-here" \
-H "Content-Type: application/json" \
-d '{
"reservationId": "64f1234567890abcdef12345",
"installmentId": "INST-001",
"chequeStatus": "collected",
"chequeNumber": "CHQ-998877",
"chequeAmount": 50000
}'

Code Examples

JavaScript/Node.js

const apiKey = process.env.SAKNEEN_API_KEY;
const baseDomain = process.env.SAKNEEN_DOMAIN;

async function sendReservationWebhook(payload) {
const response = await fetch(
`https://${baseDomain}/external/apis/v1.0/reservations/webhook`,
{
method: 'POST',
headers: {
'api-key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
},
);

if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Webhook failed (${response.status}): ${errorBody}`);
}
}

// Approve a reservation
await sendReservationWebhook({
reservationId: '64f1234567890abcdef12345',
reservationStatus: 'approved',
});

// Mark a cheque as collected
await sendReservationWebhook({
reservationId: '64f1234567890abcdef12345',
installmentId: 'INST-001',
chequeStatus: 'collected',
chequeNumber: 'CHQ-998877',
chequeAmount: 50000,
});

Python

import os
import requests

api_key = os.getenv('SAKNEEN_API_KEY')
base_domain = os.getenv('SAKNEEN_DOMAIN')

def send_reservation_webhook(payload):
headers = {
'api-key': api_key,
'Content-Type': 'application/json',
}

response = requests.post(
f'https://{base_domain}/external/apis/v1.0/reservations/webhook',
json=payload,
headers=headers,
)

response.raise_for_status()

# Approve a reservation
send_reservation_webhook({
'reservationId': '64f1234567890abcdef12345',
'reservationStatus': 'approved',
})

# Mark a cheque as bounced
send_reservation_webhook({
'reservationId': '64f1234567890abcdef12345',
'installmentId': 'INST-001',
'chequeStatus': 'bounced',
'chequeNumber': 'CHQ-998877',
'chequeAmount': 50000,
})

Python — Odoo Integration

Odoo is the typical partner integration on the OIC side. The snippet below shows how to call the Sakneen webhook from inside an Odoo model whenever a cheque or a reservation transitions on the Odoo side.

Store credentials in ir.config_parameter (under Settings → Technical → System Parameters):

KeyValue
sakneen.api_keyYour Sakneen API key
sakneen.base_domainYour Sakneen domain (e.g. api.sakneen.com)

Then in your Odoo module:

# -*- coding: utf-8 -*-
import logging
import requests

from odoo import models, fields, api
from odoo.exceptions import UserError

_logger = logging.getLogger(__name__)

SAKNEEN_WEBHOOK_PATH = "/external/apis/v1.0/reservations/webhook"
SAKNEEN_TIMEOUT_SECONDS = 30


class SakneenReservationSync(models.AbstractModel):
"""Mixin: pushes reservation/cheque transitions to Sakneen.

Add `_inherit = ['sakneen.reservation.sync']` to your reservation
or cheque model and call the helpers below from the relevant
Odoo state-change methods.
"""

_name = "sakneen.reservation.sync"
_description = "Sakneen Reservation Webhook Sync"

def _sakneen_config(self):
params = self.env["ir.config_parameter"].sudo()
api_key = params.get_param("sakneen.api_key")
base_domain = params.get_param("sakneen.base_domain")
if not api_key or not base_domain:
raise UserError(
"Sakneen integration is not configured. "
"Set 'sakneen.api_key' and 'sakneen.base_domain'."
)
return api_key, base_domain

def _sakneen_post(self, payload):
api_key, base_domain = self._sakneen_config()
url = f"https://{base_domain}{SAKNEEN_WEBHOOK_PATH}"

try:
response = requests.post(
url,
json=payload,
headers={
"api-key": api_key,
"Content-Type": "application/json",
},
timeout=SAKNEEN_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
_logger.exception("Sakneen webhook network error: %s", exc)
raise UserError(
"Could not reach Sakneen. The change was not synced."
) from exc

if response.status_code == 201:
_logger.info(
"Sakneen webhook accepted: reservation=%s payload=%s",
payload.get("reservationId"),
payload,
)
return

# Sakneen returns structured errors — surface them to the user.
try:
body = response.json()
except ValueError:
body = {"message": response.text}

_logger.warning(
"Sakneen webhook rejected (%s): %s", response.status_code, body
)

if response.status_code in (400, 422):
raise UserError(
f"Sakneen rejected the update: {body.get('message')}"
)
if response.status_code in (401, 403):
raise UserError("Sakneen authentication failed. Check the API key.")
if response.status_code == 404:
raise UserError(
f"Sakneen could not find the target: {body.get('message')}"
)
# 5xx — transient, let the caller retry (e.g. via a queued job).
raise UserError("Sakneen is temporarily unavailable. Please retry.")

# ---------- Reservation status flow ----------

def sakneen_approve_reservation(self, sakneen_reservation_id):
return self._sakneen_post({
"reservationId": sakneen_reservation_id,
"reservationStatus": "approved",
})

def sakneen_cancel_reservation(self, sakneen_reservation_id):
return self._sakneen_post({
"reservationId": sakneen_reservation_id,
"reservationStatus": "canceled",
})

# ---------- Cheque status flow ----------

def sakneen_mark_cheque_collected(
self, sakneen_reservation_id, installment_id,
cheque_number=None, cheque_amount=None,
):
payload = {
"reservationId": sakneen_reservation_id,
"installmentId": installment_id,
"chequeStatus": "collected",
}
if cheque_number is not None:
payload["chequeNumber"] = cheque_number
if cheque_amount is not None:
payload["chequeAmount"] = cheque_amount
return self._sakneen_post(payload)

def sakneen_mark_cheque_bounced(
self, sakneen_reservation_id, installment_id,
cheque_number=None, cheque_amount=None,
):
payload = {
"reservationId": sakneen_reservation_id,
"installmentId": installment_id,
"chequeStatus": "bounced",
}
if cheque_number is not None:
payload["chequeNumber"] = cheque_number
if cheque_amount is not None:
payload["chequeAmount"] = cheque_amount
return self._sakneen_post(payload)

Wiring it into an Odoo cheque model

class AccountPaymentCheque(models.Model):
_inherit = ["account.payment", "sakneen.reservation.sync"]

sakneen_reservation_id = fields.Char(string="Sakneen Reservation ID")
sakneen_installment_id = fields.Char(string="Sakneen Installment ID")

def action_mark_collected(self):
for rec in self:
res = super(AccountPaymentCheque, rec).action_mark_collected()
if rec.sakneen_reservation_id and rec.sakneen_installment_id:
rec.sakneen_mark_cheque_collected(
sakneen_reservation_id=rec.sakneen_reservation_id,
installment_id=rec.sakneen_installment_id,
cheque_number=rec.check_number,
cheque_amount=rec.amount,
)
return res

def action_mark_bounced(self):
for rec in self:
res = super(AccountPaymentCheque, rec).action_mark_bounced()
if rec.sakneen_reservation_id and rec.sakneen_installment_id:
rec.sakneen_mark_cheque_bounced(
sakneen_reservation_id=rec.sakneen_reservation_id,
installment_id=rec.sakneen_installment_id,
cheque_number=rec.check_number,
cheque_amount=rec.amount,
)
return res
tip

For production, push the _sakneen_post call into a queued job (e.g. with the OCA queue_job module) so a transient Sakneen 5xx doesn't block the Odoo workflow and so retries are automatic.

PHP

<?php
$apiKey = getenv('SAKNEEN_API_KEY');
$baseDomain = getenv('SAKNEEN_DOMAIN');

function sendReservationWebhook($payload) {
global $apiKey, $baseDomain;

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://{$baseDomain}/external/apis/v1.0/reservations/webhook");
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'api-key: ' . $apiKey,
'Content-Type: application/json',
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($httpCode < 200 || $httpCode >= 300) {
throw new Exception("Webhook failed (HTTP {$httpCode}): {$response}");
}
}

sendReservationWebhook([
'reservationId' => '64f1234567890abcdef12345',
'reservationStatus' => 'approved',
]);
?>

Response Format

On success the webhook returns HTTP 201 Created with an empty response body. There is no payload to consume — the side-effects described in Behavior are applied server-side.

Error Handling

Common Error Responses

Missing API Key

{
"statusCode": 401,
"message": "Access Denied, API key not provided"
}

Invalid API Key

{
"statusCode": 401,
"message": "Access Denied, API key not valid"
}

Validation Error

{
"statusCode": 400,
"message": [
"chequeStatus must be a valid enum value",
"reservationId must be a string"
],
"error": "Bad Request"
}

Reservation / Unit / Cheque Not Found

{
"statusCode": 404,
"message": "Reservation not found",
"error": "Not Found"
}

Other 404 messages: Unit not found, Cheque not found in wallet.

Invalid State Transition

Returned when sending reservationStatus = approved for a reservation that is not currently pending_cheque_collection:

{
"statusCode": 422,
"message": "Reservation must be pending cheque collection before OIC approval",
"error": "Unprocessable Entity"
}

Returned when recovering a bounced → collected cheque if a different reservation has already been approved for the same unit in the meantime:

{
"statusCode": 422,
"message": "This unit already reserved",
"error": "Unprocessable Entity"
}
caution

On the bounced → collected recovery path, the cheque's status, number, amount, and statusHistory are written before the "unit already reserved" check runs. If you receive that 422, the cheque update is already persisted but the reservation has not been re-approved. Reconcile by treating the cheque as collected on your side and the reservation as still canceled.

Best Practices

Idempotency

  • Sending the same chequeStatus for a cheque a second time is not a no-op — it appends another entry to the cheque's status history. Deduplicate on your side and only call the webhook when the partner-side state genuinely changes.
  • Approving a reservation that is already approved will return 422; check your local state before retrying.

Error Handling

  • Treat 4xx responses as terminal — they will not succeed on retry without changing the payload.
  • Treat 5xx and network errors as retryable. Use exponential backoff and a bounded retry budget.
  • Log the full request/response on failure for debugging.

Security

  • Store API keys in environment variables or a secret manager — never in source control.
  • Use HTTPS in production.
  • Rotate API keys on a regular cadence.

Performance

  • This endpoint is designed for low-volume control-plane events, not bulk traffic. Send one event per state change rather than batching.
  • Set a reasonable client-side timeout (10–30 seconds) — the webhook may trigger downstream business logic that takes a moment to complete.

Integration Tips

  • Test each transition in a non-production environment first: approve, cancel, cheque collected, cheque bounced, and bounced → collected recovery.
  • Keep your partner-side state in sync by updating only after receiving a successful response (HTTP 201).
  • Surface 422 messages to your operations team — they typically indicate a state mismatch between Sakneen and the partner system that needs manual reconciliation.