{
  "name": "Gaplessly - receive booking events",
  "active": false,
  "nodes": [
    {
      "id": "setup-notes",
      "name": "Setup notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -520
      ],
      "parameters": {
        "content": "## Gaplessly signed events\n1. Self-hosted n8n: start n8n with NODE_FUNCTION_ALLOW_BUILTIN=crypto so the Code node can compute the HMAC. n8n Cloud allows crypto already.\n2. Activate this workflow and copy the Webhook node Production URL (https). Test URLs do not keep de-duplication state.\n3. In Gaplessly Developer > Webhooks, add that URL and choose the events. Copy the signing secret (whsec_...), shown once.\n4. Store the secret as an n8n Variable or environment variable named GAPLESSLY_WEBHOOK_SECRET. A Code node cannot read n8n credentials, and the secret must never be pasted into this workflow.\n5. Keep Raw Body ON in the Webhook node: the signature covers the exact bytes sent, and re-serialised JSON can differ.\n6. Use Send test in Developer. The ping arrives on the ping output.",
        "height": 340,
        "width": 520
      }
    },
    {
      "id": "webhook",
      "name": "Gaplessly webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "parameters": {
        "httpMethod": "POST",
        "path": "gaplessly-events",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "notesInFlow": true,
      "notes": "Raw Body must stay ON: the signature is checked over the exact bytes Gaplessly sent."
    },
    {
      "id": "verify",
      "name": "Verify signature",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Gaplessly webhook receipt: signature, freshness, then de-duplication.\n// Gaplessly-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + \".\" + raw body)>.\n// The MAC covers the exact bytes sent, so it is checked over the raw body\n// (Webhook node option Raw Body ON), never over re-serialised JSON.\n// The secret comes from an n8n Variable or an environment variable named\n// GAPLESSLY_WEBHOOK_SECRET, never from this workflow's JSON.\nconst crypto = require('crypto');\nconst TOLERANCE_SECONDS = 300; // refuse a timestamp more than five minutes away\nconst KEEP_SECONDS = 3 * 24 * 60 * 60; // Gaplessly retries for about a day\nconst MAX_REMEMBERED = 5000;\nconst RAW_PROPERTY = 'data'; // where the Webhook node puts the raw body\nconst helpers = this.helpers;\n\nfunction readSecret() {\n  try {\n    if (typeof $vars !== 'undefined' && $vars.GAPLESSLY_WEBHOOK_SECRET) return String($vars.GAPLESSLY_WEBHOOK_SECRET);\n  } catch (e) {}\n  try {\n    if (typeof $env !== 'undefined' && $env.GAPLESSLY_WEBHOOK_SECRET) return String($env.GAPLESSLY_WEBHOOK_SECRET);\n  } catch (e) {}\n  return '';\n}\n\nfunction headerValue(headers, name) {\n  for (const key of Object.keys(headers || {})) {\n    if (key.toLowerCase() === name) {\n      const value = headers[key];\n      return Array.isArray(value) ? value[0] : value;\n    }\n  }\n  return undefined;\n}\n\nasync function rawBody(item, index) {\n  const binary = item.binary && item.binary[RAW_PROPERTY];\n  if (!binary) return null;\n  if (helpers && typeof helpers.getBinaryDataBuffer === 'function') {\n    try {\n      return (await helpers.getBinaryDataBuffer(index, RAW_PROPERTY)).toString('utf8');\n    } catch (e) {}\n  }\n  return typeof binary.data === 'string' ? Buffer.from(binary.data, 'base64').toString('utf8') : null;\n}\n\nfunction signatureMatches(raw, header, secret, now) {\n  if (typeof header !== 'string') return 'Missing Gaplessly-Signature header';\n  let t = null;\n  const v1 = [];\n  for (const part of header.split(',')) {\n    const at = part.indexOf('=');\n    if (at < 0) continue;\n    const key = part.slice(0, at).trim();\n    const value = part.slice(at + 1).trim();\n    if (key === 't' && /^\\d{1,12}$/.test(value)) t = value;\n    else if (key === 'v1' && /^[a-f0-9]{64}$/.test(value)) v1.push(Buffer.from(value, 'hex'));\n  }\n  if (t === null || v1.length === 0) return 'Malformed Gaplessly-Signature header';\n  if (Math.abs(now - Number(t)) > TOLERANCE_SECONDS) return 'Stale timestamp: older or newer than five minutes';\n  const expected = crypto.createHmac('sha256', secret).update(t + '.' + raw, 'utf8').digest();\n  // Every candidate is compared, in constant time, so a rotation's second v1 works.\n  let ok = false;\n  for (const candidate of v1) if (crypto.timingSafeEqual(expected, candidate)) ok = true;\n  return ok ? null : 'Signature does not match';\n}\n\nconst secret = readSecret();\nconst now = Math.floor(Date.now() / 1000);\nconst store = $getWorkflowStaticData('global');\nconst seen = store.gaplesslySeenEvents || (store.gaplesslySeenEvents = {});\nfor (const id of Object.keys(seen)) if (now - seen[id] > KEEP_SECONDS) delete seen[id];\n\nconst out = [];\nconst items = $input.all();\nfor (let index = 0; index < items.length; index++) {\n  const item = items[index];\n  const refuse = (httpStatus, reason) => out.push({ json: { outcome: 'rejected', httpStatus, reason } });\n  if (!secret) {\n    // 500 so Gaplessly retries once the secret is set.\n    refuse(500, 'GAPLESSLY_WEBHOOK_SECRET is not set in n8n');\n    continue;\n  }\n  const raw = await rawBody(item, index);\n  if (raw === null) {\n    refuse(500, 'No raw body: turn on Raw Body in the Webhook node options');\n    continue;\n  }\n  const problem = signatureMatches(raw, headerValue(item.json.headers, 'gaplessly-signature'), secret, now);\n  if (problem) {\n    refuse(401, problem);\n    continue;\n  }\n  let event;\n  try {\n    event = JSON.parse(raw);\n  } catch (e) {\n    refuse(400, 'Body is not JSON');\n    continue;\n  }\n  if (!event || typeof event.id !== 'string' || typeof event.type !== 'string') {\n    refuse(400, 'Not a Gaplessly event');\n    continue;\n  }\n  if (seen[event.id]) {\n    out.push({ json: { outcome: 'duplicate', httpStatus: 200, id: event.id, type: event.type } });\n    continue;\n  }\n  seen[event.id] = now;\n  out.push({ json: { outcome: 'accepted', httpStatus: 200, id: event.id, type: event.type, createdAt: event.createdAt, data: event.data } });\n}\n\nconst ids = Object.keys(seen);\nif (ids.length > MAX_REMEMBERED) {\n  ids.sort((a, b) => seen[a] - seen[b]);\n  for (const id of ids.slice(0, ids.length - MAX_REMEMBERED)) delete seen[id];\n}\nreturn out;\n"
      },
      "notesInFlow": true,
      "notes": "Reads GAPLESSLY_WEBHOOK_SECRET from an n8n Variable or environment variable. Rejects tampered or stale requests and skips an event id already accepted."
    },
    {
      "id": "outcome",
      "name": "Outcome",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        440,
        0
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "accepted",
                    "leftValue": "={{ $json.outcome }}",
                    "rightValue": "accepted",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "accepted"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "duplicate",
                    "leftValue": "={{ $json.outcome }}",
                    "rightValue": "duplicate",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "duplicate"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "rejected",
                    "leftValue": "={{ $json.outcome }}",
                    "rightValue": "rejected",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "rejected"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "respond-accepted",
      "name": "Respond accepted",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        660,
        -160
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ received: true }) }}",
        "options": {
          "responseCode": 200
        }
      },
      "notesInFlow": true,
      "notes": "Answers within Gaplessly 8 second deadline, before the slower steps run."
    },
    {
      "id": "respond-duplicate",
      "name": "Respond duplicate",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        660,
        0
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ received: true, duplicate: true }) }}",
        "options": {
          "responseCode": 200
        }
      },
      "notesInFlow": true,
      "notes": "A retry of an event already handled: acknowledged so Gaplessly stops retrying, and nothing runs twice."
    },
    {
      "id": "respond-rejected",
      "name": "Respond rejected",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        660,
        160
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ error: $json.reason }) }}",
        "options": {
          "responseCode": "={{ $json.httpStatus }}"
        }
      }
    },
    {
      "id": "route",
      "name": "Route by event type",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        1100,
        -160
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "booking.created",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "booking.created",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "booking.created"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "booking.updated",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "booking.updated",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "booking.updated"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "booking.cancelled",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "booking.cancelled",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "booking.cancelled"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "guest.created",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "guest.created",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "guest.created"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "guest.merged",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "guest.merged",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "guest.merged"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "id": "ping",
                    "leftValue": "={{ $json.type }}",
                    "rightValue": "ping",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "ping"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "Other"
        }
      },
      "notesInFlow": true,
      "notes": "Delivery order is not guaranteed. Each body carries the booking as it was when sent."
    },
    {
      "id": "handle-booking-created",
      "name": "On booking.created",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1320,
        -460
      ],
      "parameters": {},
      "notesInFlow": true,
      "notes": "Replace with your steps. Use data.object.id as the booking reference; data.changes lists what changed."
    },
    {
      "id": "handle-booking-updated",
      "name": "On booking.updated",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1320,
        -320
      ],
      "parameters": {},
      "notesInFlow": true,
      "notes": "Replace with your steps. Use data.object.id as the booking reference; data.changes lists what changed."
    },
    {
      "id": "handle-booking-cancelled",
      "name": "On booking.cancelled",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1320,
        -180
      ],
      "parameters": {},
      "notesInFlow": true,
      "notes": "Replace with your steps. Use data.object.id as the booking reference; data.changes lists what changed."
    },
    {
      "id": "handle-guest-created",
      "name": "On guest.created",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1320,
        -40
      ],
      "parameters": {},
      "notesInFlow": true,
      "notes": "Replace with your steps. data.object carries the guest name, email and phone."
    },
    {
      "id": "handle-guest-merged",
      "name": "On guest.merged",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1320,
        100
      ],
      "parameters": {},
      "notesInFlow": true,
      "notes": "Replace with your steps. data.object carries the guest name, email and phone."
    },
    {
      "id": "handle-ping",
      "name": "On ping",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1320,
        240
      ],
      "parameters": {},
      "notesInFlow": true,
      "notes": "The Send test event from Developer."
    },
    {
      "id": "handle-other",
      "name": "On other event",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1320,
        380
      ],
      "parameters": {}
    }
  ],
  "connections": {
    "Gaplessly webhook": {
      "main": [
        [
          {
            "node": "Verify signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify signature": {
      "main": [
        [
          {
            "node": "Outcome",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Outcome": {
      "main": [
        [
          {
            "node": "Respond accepted",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond duplicate",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond rejected",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond accepted": {
      "main": [
        [
          {
            "node": "Route by event type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by event type": {
      "main": [
        [
          {
            "node": "On booking.created",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On booking.updated",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On booking.cancelled",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On guest.created",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On guest.merged",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On ping",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "On other event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "tags": []
}
