Webhooks

API version 2026-11-01 · Updated October 16, 2026 · Español

With webhooks, Zenzey tells your systems when something happens in your institution: a new recap, a task marked done, the hour pool running low. Each event is an HTTPS POST with a JSON body, signed so you can check it came from us. Available on the Organization plan.

Quick start

  1. In platform.zenzey.app › Integrations › Webhooks, create a webhook with your endpoint’s URL and the events you want. Only coordinators can create them.
  2. Copy the signing secret (whsec_…). It is shown once; if you lose it, rotate it.
  3. Verify the signature of every request (below) and reject the ones that don’t match.
  4. Answer with any 2xx within 10 seconds. Do the slow work after answering.
  5. Press Send test and check the result in the delivery log.

The request

POST /zenzey HTTP/1.1
content-type: application/json
user-agent: Zenzey-Webhooks/1 (+https://zenzey.app/developers/webhooks)
webhook-id: evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20
webhook-timestamp: 1793740445
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
zenzey-event-type: task.completed
zenzey-delivery-id: 3f2a9c1e-5b7d-4e0a-9c8b-1d2e3f4a5b6c
zenzey-attempt: 1
HeaderWhat it is
webhook-idThe event id. The same on every retry and manual resend: use it to discard duplicates.
webhook-timestampUnix seconds of this attempt.
webhook-signatureOne or two v1,<base64> signatures, separated by a space.
zenzey-event-typeThe event type, so you can route before parsing.
zenzey-delivery-idThis delivery (one event, one endpoint). Quote it when you write to us.
zenzey-attempt1 for the first attempt, then 2, 3…

Every body has the same envelope:

{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "webhook.ping",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "message": "Test webhook from Zenzey",
    "endpoint": {
      "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
      "host": "hooks.example.com"
    }
  }
}

created_at is when the event happened, not when it was delivered. organization is your institution. data depends on the type.

Events

TypeWhenContent
session.recordedA session’s audio finished uploading.—
session.failedA session could not be transcribed or written up.—
session.deletedA session in one of the institution’s bands was deleted.—
session.recap_readyA session’s recap was written for the first time.yes
session.recap_regeneratedA session’s recap was written again. Replace everything you stored for it.yes
task.createdSomeone added a task or open item to a recap.yes
task.updatedA task’s text, assignee or type changed. Edits within 30 seconds are grouped.yes
task.completedSomeone marked a task as done.yes
task.reopenedA done task went back to pending.yes
task.deletedA task was removed from the recap, or reclassified as something else.yes
usage.threshold_reachedThe month’s hour pool went past 80%, 100% or 110%. Once per month and threshold.—
webhook.pingThe “Send test” button in the portal. Not subscribable.—

Events are only sent for sessions recorded in one of the institution’s bands. Each event, as it arrives:

session.recorded
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "session.recorded",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "session": {
      "id": "0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "title": "Ensayo del martes",
      "kind": "rehearsal",
      "recorded_at": "2026-11-03T19:30:00.000Z",
      "duration_seconds": 5412,
      "language": "es",
      "band": {
        "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "name": "Lumbre"
      },
      "portal_url": "https://platform.zenzey.app/sessions/0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e"
    }
  }
}
session.failed
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "session.failed",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "session": {
      "id": "0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "title": "Ensayo del martes",
      "kind": "rehearsal",
      "recorded_at": "2026-11-03T19:30:00.000Z",
      "duration_seconds": 5412,
      "language": "es",
      "band": {
        "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "name": "Lumbre"
      },
      "portal_url": "https://platform.zenzey.app/sessions/0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e"
    },
    "stage": "transcription",
    "failed_at": "2026-11-03T21:14:05.000Z"
  }
}
session.deleted
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "session.deleted",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "session": {
      "id": "0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "recorded_at": "2026-11-03T19:30:00.000Z",
      "band": {
        "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "name": "Lumbre"
      }
    },
    "deleted_at": "2026-11-03T21:14:05.000Z"
  }
}
session.recap_ready
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "session.recap_ready",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "session": {
      "id": "0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "title": "Ensayo del martes",
      "kind": "rehearsal",
      "recorded_at": "2026-11-03T19:30:00.000Z",
      "duration_seconds": 5412,
      "language": "es",
      "band": {
        "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "name": "Lumbre"
      },
      "portal_url": "https://platform.zenzey.app/sessions/0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "public_url": "https://zenzey.app/a/k3v9x2"
    },
    "recap": {
      "version": "2026-11-03T21:14:03.000Z",
      "summary": "Ensayaron dos canciones; el puente de «Luz de paso» quedó a medio tiempo.",
      "songs": [
        {
          "id": "c1",
          "name": "Luz de paso",
          "key": "F#m",
          "takes": 3
        },
        {
          "id": "c2",
          "name": "Puente de sal",
          "key": "D",
          "takes": 2
        }
      ],
      "items": [
        {
          "id": "i1",
          "kind": "decision",
          "text": "El puente de «Luz de paso» va a medio tiempo.",
          "assignee": null,
          "done": false,
          "song_id": "c1",
          "origin": "ai"
        },
        {
          "id": "i2",
          "kind": "task",
          "text": "Pasar la letra corregida del segundo verso.",
          "assignee": "Rocío",
          "done": false,
          "song_id": "c2",
          "origin": "ai"
        },
        {
          "id": "i3",
          "kind": "open_issue",
          "text": "Definir el final de «Puente de sal».",
          "assignee": null,
          "done": false,
          "song_id": "c2",
          "origin": "ai"
        }
      ]
    }
  }
}
session.recap_regenerated
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "session.recap_regenerated",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "session": {
      "id": "0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "title": "Ensayo del martes",
      "kind": "rehearsal",
      "recorded_at": "2026-11-03T19:30:00.000Z",
      "duration_seconds": 5412,
      "language": "es",
      "band": {
        "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "name": "Lumbre"
      },
      "portal_url": "https://platform.zenzey.app/sessions/0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "public_url": "https://zenzey.app/a/k3v9x2"
    },
    "recap": {
      "version": "2026-11-03T21:14:03.000Z",
      "summary": "Ensayaron dos canciones; el puente de «Luz de paso» quedó a medio tiempo.",
      "songs": [
        {
          "id": "c1",
          "name": "Luz de paso",
          "key": "F#m",
          "takes": 3
        },
        {
          "id": "c2",
          "name": "Puente de sal",
          "key": "D",
          "takes": 2
        }
      ],
      "items": [
        {
          "id": "i1",
          "kind": "decision",
          "text": "El puente de «Luz de paso» va a medio tiempo.",
          "assignee": null,
          "done": false,
          "song_id": "c1",
          "origin": "ai"
        },
        {
          "id": "i2",
          "kind": "task",
          "text": "Pasar la letra corregida del segundo verso.",
          "assignee": "Rocío",
          "done": false,
          "song_id": "c2",
          "origin": "ai"
        },
        {
          "id": "i3",
          "kind": "open_issue",
          "text": "Definir el final de «Puente de sal».",
          "assignee": null,
          "done": false,
          "song_id": "c2",
          "origin": "ai"
        }
      ]
    }
  }
}
task.created
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "task.created",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "session": {
      "id": "0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "title": "Ensayo del martes",
      "kind": "rehearsal",
      "recorded_at": "2026-11-03T19:30:00.000Z",
      "duration_seconds": 5412,
      "language": "es",
      "band": {
        "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "name": "Lumbre"
      },
      "portal_url": "https://platform.zenzey.app/sessions/0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "public_url": "https://zenzey.app/a/k3v9x2"
    },
    "recap_version": "2026-11-03T21:14:03.000Z",
    "task": {
      "id": "i2",
      "kind": "task",
      "text": "Pasar la letra corregida del segundo verso.",
      "assignee": "Rocío",
      "done": false,
      "song": {
        "id": "c2",
        "name": "Puente de sal"
      },
      "origin": "ai"
    }
  }
}
task.updated
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "task.updated",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "session": {
      "id": "0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "title": "Ensayo del martes",
      "kind": "rehearsal",
      "recorded_at": "2026-11-03T19:30:00.000Z",
      "duration_seconds": 5412,
      "language": "es",
      "band": {
        "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "name": "Lumbre"
      },
      "portal_url": "https://platform.zenzey.app/sessions/0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "public_url": "https://zenzey.app/a/k3v9x2"
    },
    "recap_version": "2026-11-03T21:14:03.000Z",
    "task": {
      "id": "i2",
      "kind": "task",
      "text": "Pasar la letra corregida del segundo verso.",
      "assignee": "Matías",
      "done": false,
      "song": {
        "id": "c2",
        "name": "Puente de sal"
      },
      "origin": "ai"
    },
    "previous": {
      "assignee": "Rocío"
    }
  }
}
task.completed
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "task.completed",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "session": {
      "id": "0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "title": "Ensayo del martes",
      "kind": "rehearsal",
      "recorded_at": "2026-11-03T19:30:00.000Z",
      "duration_seconds": 5412,
      "language": "es",
      "band": {
        "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "name": "Lumbre"
      },
      "portal_url": "https://platform.zenzey.app/sessions/0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "public_url": "https://zenzey.app/a/k3v9x2"
    },
    "recap_version": "2026-11-03T21:14:03.000Z",
    "task": {
      "id": "i2",
      "kind": "task",
      "text": "Pasar la letra corregida del segundo verso.",
      "assignee": "Rocío",
      "done": true,
      "song": {
        "id": "c2",
        "name": "Puente de sal"
      },
      "origin": "ai"
    }
  }
}
task.reopened
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "task.reopened",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "session": {
      "id": "0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "title": "Ensayo del martes",
      "kind": "rehearsal",
      "recorded_at": "2026-11-03T19:30:00.000Z",
      "duration_seconds": 5412,
      "language": "es",
      "band": {
        "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "name": "Lumbre"
      },
      "portal_url": "https://platform.zenzey.app/sessions/0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "public_url": "https://zenzey.app/a/k3v9x2"
    },
    "recap_version": "2026-11-03T21:14:03.000Z",
    "task": {
      "id": "i2",
      "kind": "task",
      "text": "Pasar la letra corregida del segundo verso.",
      "assignee": "Rocío",
      "done": false,
      "song": {
        "id": "c2",
        "name": "Puente de sal"
      },
      "origin": "ai"
    }
  }
}
task.deleted
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "task.deleted",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "session": {
      "id": "0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "title": "Ensayo del martes",
      "kind": "rehearsal",
      "recorded_at": "2026-11-03T19:30:00.000Z",
      "duration_seconds": 5412,
      "language": "es",
      "band": {
        "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "name": "Lumbre"
      },
      "portal_url": "https://platform.zenzey.app/sessions/0b6c1d2e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
      "public_url": "https://zenzey.app/a/k3v9x2"
    },
    "recap_version": "2026-11-03T21:14:03.000Z",
    "reason": "removed",
    "task": {
      "id": "i2",
      "kind": "task",
      "text": "Pasar la letra corregida del segundo verso.",
      "assignee": "Rocío",
      "done": false,
      "song": {
        "id": "c2",
        "name": "Puente de sal"
      },
      "origin": "ai"
    }
  }
}
usage.threshold_reached
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "usage.threshold_reached",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "period": "2026-11",
    "state": "80_percent",
    "hours_used": 80.5,
    "hours_included": 100,
    "hours_limit": 110
  }
}
webhook.ping
{
  "id": "evt_7c0e1b3f0a8c4a4f9b6f2d4c8e1a9b20",
  "type": "webhook.ping",
  "api_version": "2026-11-01",
  "created_at": "2026-11-03T21:14:05.000Z",
  "organization": {
    "id": "6f1c2a7e-3b4d-4e8f-9a01-2b3c4d5e6f70",
    "name": "Escuela Vértice"
  },
  "data": {
    "message": "Test webhook from Zenzey",
    "endpoint": {
      "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
      "host": "hooks.example.com"
    }
  }
}

In recap.items, kind is decision, task, open_issue or idea. Items with origin: "ai" may get new ids when the recap is regenerated; items with origin: "person" keep theirs. After session.recap_regenerated, replace that session’s items wholesale.

Session content and privacy

Events marked content carry what the portal shows when someone opens a session: the summary, the songs, and the text of decisions and tasks, which may name people (often students). So:

  • A webhook only receives content if a coordinator explicitly authorized it for that domain. If the URL moves to another domain, content stops until someone authorizes it again.
  • Every delivery of content is written to the institution’s access log, the same log that records who opened which session, at most once per webhook, session and day.
  • Never sent: transcripts, audio, verbatim quotes, who recorded a session, account ids or emails, band rosters, or any count of activity per person.
  • When a session is deleted you receive session.deleted: delete what you stored about it. The institution is responsible for what its systems do with the content they receive.

Verifying signatures

Zenzey follows Standard Webhooks: the signature is an HMAC-SHA256 of ${webhook-id}.${webhook-timestamp}.${body}, keyed with the bytes of your secret (the base64 after whsec_), and base64-encoded. Compute it over the raw body, before parsing it. The official standardwebhooks libraries for Node, Python, Go, Ruby, PHP and others work as they are. Without a dependency, in Node:

import { createHmac, timingSafeEqual } from 'node:crypto';

/** true if the request was signed by Zenzey with this secret. */
export function verifyZenzeyWebhook(secret, headers, rawBody) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) return false;
  // Reject old or future timestamps (replay protection).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();

  // During a secret rotation there are two signatures, separated by a space.
  return signatures.split(' ').some((part) => {
    const [version, value] = part.split(',');
    if (version !== 'v1' || !value) return false;
    const received = Buffer.from(value, 'base64');
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

With Express:

import express from 'express';
import { verifyZenzeyWebhook } from './verify.js';

const app = express();

// The signature covers the RAW body: read it as text, parse it after verifying.
app.post('/zenzey', express.text({ type: 'application/json' }), (req, res) => {
  if (!verifyZenzeyWebhook(process.env.ZENZEY_WEBHOOK_SECRET, req.headers, req.body)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  res.status(204).end();           // answer first (within 10 seconds)…
  queueForProcessing(event);       // …and do the work afterwards.
});

In Python:

import base64, hashlib, hmac, time

def verify_zenzey_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature", "")
    if not msg_id or not timestamp or abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        part.startswith("v1,") and hmac.compare_digest(part[3:], expected)
        for part in signatures.split(" ")
    )

Rotating the secret. From the webhook’s page you can rotate keeping the old secret valid for 24 hours (deliveries carry both signatures meanwhile, so you can swap it without losing anything) or revoke it at once if it leaked.

Retries and failures

  • A delivery succeeds when your endpoint answers 2xx within 10 seconds. Redirects are not followed.
  • Otherwise it is retried after about 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h and 24 h: 9 attempts over almost three days. A Retry-After header is honoured up to six hours.
  • Answer 410 Gone to tell us the endpoint no longer exists: the webhook is disabled right away and the coordinators get an email.
  • A webhook that fails for five days without a single success is disabled automatically, and the coordinators get an email.
  • Any delivery can be resent by hand from the delivery log for 30 days.

Delivery is at least once: the same event can arrive twice, always with the same webhook-id. There is no ordering guarantee: two events about the same session can arrive in reverse. Content events carry the full current state (and recap.version / recap_version), so the newest one wins.

Endpoint requirements and limits

  • HTTPS on port 443, with a domain name that resolves to public addresses. Private networks, IP addresses and Zenzey’s own domains are rejected.
  • Up to 10 webhooks per institution, each with an explicit list of events.
  • Bodies are at most 256 KB; a very long recap is cut and marked with "truncated": true.
  • One test every 10 seconds per webhook.
  • We don’t publish a list of IP addresses: verify the signature instead, which proves the origin better than an IP.

Versioning

Each event carries its api_version (currently 2026-11-01). New fields and new event types may appear without a new version, so ignore what you don’t know. A change that could break a receiver comes with a new version and notice in advance.

Changelog

  • 2026-11-01 — First version: sessions, recaps, tasks and the hour pool.

Questions or something missing? info@dustkey.com.

Privacy · Terms · © Zenzey