Salta al contingut

Desenvolupadors

Webhooks

Esdeveniments de reserva, format del payload, verificació de signatura, reintents i desactivació.

Els webhooks t'avisen per HTTP tan bon punt canvia una reserva, perquè no hagis de consultar l'API contínuament. Pranarp Club envia una petició POST amb un cos JSON signat a l'URL que configuris.

Esdeveniments

Esdeveniment Quan s'envia
booking.created Es crea una reserva, des de l'API, el personal del club o un jugador. Les reserves en esborrany també es notifiquen, amb status: "draft"; en confirmar l'esborrany arriba un booking.updated.
booking.updated Canvia un camp de la reserva que exposa l'API (hora, espai, notes, titular, preu…). Diverses edicions en una mateixa operació generen un únic esdeveniment; els canvis que només afecten camps no exposats no en generen cap.
booking.cancelled Es cancel·la una reserva. S'emet una sola vegada, la primera que passa a canceled.

Configurar un endpoint

  1. Ves a Configuració → Integracions (requereix la funció integrations).
  2. A Endpoints de webhooks, prem crear i indica l'URL, una descripció opcional, els esdeveniments als quals et subscrius i, si vols, els espais (sense cap s'envien els de tot el club).
  3. Copia el secret de signatura (whsec_…). Es mostra una sola vegada.

L'URL ha de ser HTTPS, pública i sense usuari ni contrasenya. Es rebutgen les destinacions que resolen a adreces privades o locals (per exemple localhost o 10.x.x.x). Per desenvolupar en local, exposa el teu servidor amb un túnel HTTPS.

Format de la petició

POST /webhooks/pranarp HTTP/1.1
Content-Type: application/json
User-Agent: PranarpClub-Webhooks/1
X-PranarpClub-Timestamp: 1792692000
X-PranarpClub-Signature: sha256=5d41402abc4b2a76b9719d911017c592...
{
  "id": "evt_01j9z3k8m2x4v6b7n8p9q0r1s2",
  "type": "booking.created",
  "created_at": "2026-10-22T17:40:12+02:00",
  "data": {
    "booking": {
      "id": 1234,
      "space_id": 7,
      "starts_at": "2026-10-22T18:00:00+02:00",
      "ends_at": "2026-10-22T19:00:00+02:00",
      "status": "confirmed",
      "source": "api",
      "external_ref": "comanda-9981",
      "notes": null,
      "headcount": null,
      "holder": { "name": "Laura Pons", "email": null, "phone": null },
      "players": [],
      "price": { "amount_cents": 2400, "currency": "EUR", "paid_externally": true },
      "canceled_at": null,
      "created_at": "2026-10-22T17:40:12+02:00",
      "updated_at": "2026-10-22T17:40:12+02:00"
    }
  }
}
  • id identifica l'esdeveniment i es manté igual en els reintents i reenviaments. Fes-lo servir per deduplicar.
  • data.booking és la mateixa representació que retorna GET /bookings/{id}, en el seu estat final després del canvi.

Verificar la signatura

Cada petició porta dues capçaleres:

  • X-PranarpClub-Timestamp: segons Unix en el moment de l'enviament.
  • X-PranarpClub-Signature: sha256=<hex>, l'HMAC-SHA256 de la cadena {timestamp}.{cos} (el timestamp, un punt i el cos cru de la petició) calculat amb el secret de l'endpoint (whsec_…).

Passos:

  1. Llegeix el cos sense modificar (abans de decodificar el JSON).
  2. Recalcula l'HMAC i compara'l amb la capçalera en temps constant.
  3. Rebutja la petició si el timestamp s'allunya més de 5 minuts del teu rellotge (protecció contra repeticions).

PHP

<?php
function verifyPranarpSignature(string $rawBody, string $secret, int $tolerance = 300): bool
{
    $timestamp = $_SERVER['HTTP_X_PRANARPCLUB_TIMESTAMP'] ?? '';
    $signature = $_SERVER['HTTP_X_PRANARPCLUB_SIGNATURE'] ?? '';

    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }

    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    return hash_equals($expected, $signature);
}

$rawBody = file_get_contents('php://input');
if (!verifyPranarpSignature($rawBody, getenv('PRANARP_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}

$event = json_decode($rawBody, true);
// ... posa el processament en cua i respon de seguida
http_response_code(204);

JavaScript (Node.js)

const crypto = require('node:crypto');
const express = require('express');

const app = express();
const SECRET = process.env.PRANARP_WEBHOOK_SECRET;

function verify(rawBody, timestamp, signature, tolerance = 300) {
  if (!/^\d+$/.test(timestamp ?? '')) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;

  const expected = 'sha256=' + crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature ?? '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Important: el cos cru, no el JSON ja parsejat.
app.post('/webhooks/pranarp', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  const ok = verify(raw, req.get('X-PranarpClub-Timestamp'), req.get('X-PranarpClub-Signature'));
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(raw);
  // ... posa el processament en cua i respon de seguida
  res.sendStatus(204);
});

app.listen(3000);

Per provar la teva verificació, pots generar una signatura a mà:

BODY='{"id":"evt_test"}'
TS=$(date +%s)
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.* //')"
echo "$TS $SIG"

Lliurament, reintents i desactivació

  • Respon amb qualsevol estat 2xx en menys de 10 segons. Fes la feina pesada després, en una cua.
  • No se segueixen redireccions (un 3xx compta com a fallada).
  • Si respons amb un altre estat, o hi ha un error de xarxa o de temps d'espera, Pranarp Club reintenta fins a 8 vegades més amb espera exponencial: 15 s, 30 s, 1 min, 2 min… fins a uns 32 min (9 intents en total).
  • Després de 20 lliuraments consecutius que esgotin els reintents, l'endpoint es desactiva i l'has de reactivar des del tauler. Qualsevol lliurament correcte reinicia el compte.
  • Els lliuraments es conserven 30 dies. Des del tauler en pots veure l'historial (estat, intents, resposta) i reenviar-los; un reenviament porta el payload original (mateix id d'esdeveniment) signat amb un timestamp nou.

Bones pràctiques

  • El lliurament és «almenys una vegada» i sense garantia d'ordre. Deduplica per id i, si l'ordre importa, compara l'updated_at de la reserva o torna-la a consultar amb GET /bookings/{id}.
  • Verifica sempre la signatura i el timestamp abans de processar res.
  • Respon ràpid i processa de manera asíncrona.
  • Si el teu endpoint es desactiva, corregeix-ne la causa abans de reactivar-lo i, si cal, reenvia els lliuraments perduts.