# Guide to Jamie Webhooks: Features and Best Practices

Jamie webhooks deliver your meeting notes to your own systems as soon as each meeting is processed. Every webhook carries the summary, the full transcript, the action items and the attendees, so teams use them to file notes in a CRM, create tickets from tasks, archive transcripts and trigger follow-up workflows without copying anything out of Jamie, the AI meeting note-taker.

This guide covers how Jamie webhooks are created and authenticated, how to verify the signature, the `meeting.completed` payload, Jamie's retry schedule, and the practices that make a Jamie receiver reliable.

## What are Jamie webhooks?

A Jamie webhook sends one event, `meeting.completed`, to an HTTPS URL as a JSON `POST`. There are two kinds:

* A **personal webhook** fires only for its creator's meetings. Any workspace member can create one, up to 3 per user.
* A **workspace webhook** fires for every meeting in the workspace. Only admins can create one, up to 5 per workspace.

When you create a webhook, you choose how it authenticates, and you can't see the secret again afterwards:

* **API Key**, which Jamie labels "Recommended", sends a static key (`sk_...`) in `x-jamie-api-key`, or in a header you name. It proves who sent the request but says nothing about the body.
* **Signature Verification**, which Jamie labels "Advanced", signs each request with HMAC-SHA256 and sends the result in `x-jamie-signature`. It "verifies both authenticity and payload integrity".

Choose Signature Verification. Besides being the stronger option, it is the only one Hookdeck's Jamie source can verify.

## Jamie webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Settings** > **Integrations** > **Webhooks** > **Create Webhook** |
| Availability | Paid plans. Jamie's docs say "Plus or higher" in one place and list Pro, Team and Enterprise in another |
| Scopes | Personal (3 per user) or workspace (5 per workspace, admins only) |
| Events | `meeting.completed` |
| Method and format | `POST`, `Content-Type: application/json` |
| Signature header | `x-jamie-signature: t=<timestamp>,v0=<signature>` |
| Scheme | HMAC-SHA256, lowercase hex |
| Signed content | `{t}.{raw body}` |
| Secret | Shown once at creation. Regenerate it from the webhook settings if lost |
| Other headers | `user-agent: Jamie-Webhooks/1.0`, `jamie-event`, `jamie-delivery` |
| Replay window | Jamie recommends rejecting timestamps older than 5 minutes |
| Response window | `2xx` within 30 seconds |
| Retries | 5 attempts in all: immediately, then after 10 s, 60 s, 10 min and 1 h |
| After the last failure | Dead-lettered, and the owner or workspace admins are emailed (at most once an hour per endpoint) |
| Testing | A **Test** button sends a mock payload through the normal delivery path |
| Source IPs | Not published |

## Common events

Jamie currently sends one event, `meeting.completed`, when a meeting "has been fully processed". Here is Jamie's example, trimmed:

```json
{
  "metadata": {
    "id": "7893456789012345678",
    "event": "meeting.completed",
    "created": 1701234567
  },
  "data": {
    "title": "Q4 Planning Meeting",
    "startTime": "2024-11-27T14:00:00.000Z",
    "endTime": "2024-11-27T15:00:00.000Z",
    "user": { "id": "user-456", "email": "sarah.johnson@example.com" },
    "summary": {
      "markdown": "# Meeting Summary\n\n## Key Discussion Points\n...",
      "html": "<h1>Meeting Summary</h1>...",
      "short": "Q4 product planning meeting covering roadmap, mobile redesign, and user research budget allocation."
    },
    "transcript": [
      { "speakerId": "1", "speakerName": "Sarah Johnson", "text": "Hi everyone! Thanks for joining today's meeting." }
    ],
    "participants": [
      { "id": "1", "name": "Sarah Johnson", "email": "sarah.johnson@example.com" }
    ],
    "event": {
      "id": "calendar-event-123",
      "externalId": "AAMkAGI1AAAt9AHjAAA=",
      "title": "Q4 Planning Meeting",
      "scheduledTime": "2024-11-27T14:00:00.000Z",
      "endTime": "2024-11-27T15:00:00.000Z",
      "attendees": [
        { "name": "Sarah Johnson", "email": "sarah.johnson@example.com", "responseStatus": "accepted", "organizer": true }
      ]
    },
    "tasks": [
      { "content": "Schedule follow-up meeting for next week", "completed": false, "assignee": null }
    ],
    "tags": [{ "name": "Planning", "color": "#50C878" }]
  }
}

```

* `metadata.id` identifies the delivery and matches the `jamie-delivery` header. `metadata.event` matches `jamie-event`.
* `metadata.created` is in Unix seconds. The meeting times are ISO 8601 strings.
* `participants` are people who spoke, detected from the transcript. `data.event.attendees` are people invited to the calendar event, who may not have attended. Don't treat them as the same list.
* `data.event.externalId` is the calendar provider's event ID, from Microsoft Graph or Google Calendar, so you can match the meeting to your own calendar data. It and `data.event.id` can be `null`, and `attendees` is empty when no calendar event is linked.
* `endTime`, participant emails and task assignees can all be `null`.

Bodies can be large: a long meeting's transcript is in every delivery.

## Setting up Jamie webhooks

Have your endpoint ready to receive, because the secret is shown only once.

1. In Jamie, go to **Settings** > **Integrations** > **Webhooks** and click **Create Webhook**.
2. Enter your HTTPS **URL** and, optionally, a **Description**.
3. Under **Events**, select `meeting.completed`.
4. Choose **Personal webhook** or **Workspace webhook**.
5. Choose **Signature Verification** as the authentication method.
6. Click **Create**, then copy the signing secret straight away, for example into `JAMIE_WEBHOOK_SECRET`.
7. Click **Test** on the webhook to send a mock payload.

To route through Hookdeck, create a source with the **Jamie** type, use its URL as the webhook URL, and paste the signing secret into **Webhook Signing Secret**. API key mode isn't supported: a webhook created with API Key sends no signature, so every request would fail verification. If you already have an API key webhook, create a new one with Signature Verification.

## Securing Jamie webhooks

Split `x-jamie-signature` on commas and each part on its first `=`, take `t` and `v0`, compute an HMAC-SHA256 of `{t}.{raw body}` with the signing secret as a UTF-8 string, hex-encode it, and compare it with `v0` in constant time.

Jamie's docs recommend rejecting timestamps older than 5 minutes when Jamie calls you directly. The check is optional in the code below and off by default, as it is in the Jamie skill, where `JAMIE_TIMESTAMP_TOLERANCE_SECONDS` defaults to `0` and you set `300` to follow Jamie's recommendation. Behind Hookdeck, leave it at `0`: Hookdeck's retries and replays resend the original `t`, and so do Jamie's own retries, the last of which comes more than an hour after the first attempt.

```javascript
const crypto = require('crypto');
const express = require('express');

function parseJamieHeader(header) {
  let t;
  const v0 = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('='); // split on the first '=' only
    if (i <= 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') t = value;
    else if (key === 'v0') v0.push(value);
  }
  return { t, v0 };
}

function verifyJamieSignature(rawBody, signatureHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !secret) return false;
  const { t, v0 } = parseJamieHeader(signatureHeader);
  if (!t || !/^\d+$/.test(t) || v0.length === 0) return false;

  // Optional replay check, off by default. t is in seconds.
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) {
    return false;
  }

  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
  const computed = Buffer.from(expected, 'utf8');
  return v0.some((sig) => {
    const received = Buffer.from(sig.toLowerCase(), 'utf8');
    return received.length === computed.length && crypto.timingSafeEqual(received, computed);
  });
}

const app = express();

app.post('/webhooks/jamie', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.JAMIE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const tolerance = Number(process.env.JAMIE_TIMESTAMP_TOLERANCE_SECONDS || 0);
  if (!verifyJamieSignature(req.body, req.get('x-jamie-signature'), secret, tolerance)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.metadata.id, queue the meeting, then respond.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import time

def verify_jamie_signature(raw_body: bytes, signature_header, secret: str, tolerance_seconds: int = 0) -> bool:
    if not signature_header or not secret:
        return False
    t, v0 = None, []
    for part in signature_header.split(","):
        key, sep, value = part.partition("=")  # first '=' only
        if not sep:
            continue
        if key.strip() == "t":
            t = value.strip()
        elif key.strip() == "v0":
            v0.append(value.strip().lower())
    if not t or not (t.isascii() and t.isdigit()) or not v0:
        return False
    # Optional replay check, off by default. t is in seconds.
    if tolerance_seconds > 0 and abs(time.time() - int(t)) > tolerance_seconds:
        return False

    expected = hmac.new(secret.encode("utf-8"), t.encode("utf-8") + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(sig.encode("utf-8"), expected.encode("utf-8")) for sig in v0)

# In FastAPI: raw_body = await request.body()
# verify_jamie_signature(raw_body, request.headers.get("x-jamie-signature"),
#                        os.environ["JAMIE_WEBHOOK_SECRET"])

```

Jamie's own Node example verifies `JSON.stringify(req.body)` after `express.json()` has parsed the request. Re-serialising the JSON can change the bytes, for example the escaping of characters in a transcript, and the signature then fails. Verify the raw bytes, as above.

To check your verifier, use the signing secret `jamie-guide-test-signing-secret`, a header timestamp of `1761400000` and this exact body:

```
{"metadata":{"id":"7893456789012345678","event":"meeting.completed","created":1761400000},"data":{"title":"Q4 Planning Meeting"}}

```

The `v0` value should be:

```
1bbc7a192fe41d7d65d98642b28921f59cacfc454aba933886d8d257687fd567

```

> **Make Jamie webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `x-jamie-signature` header on every meeting, answers Jamie immediately, and keeps each transcript durably until your service has processed it.

## Jamie webhook limitations and pain points

### The default mode isn't signed

**The Problem:** Your webhook authenticates the sender, but a modified body still passes, and Hookdeck's Jamie source rejects every request.

**Why It Happens:** Jamie recommends API Key mode, and it is the default. It sends a static key and no signature. The choice is made when you create the webhook.

**Workarounds:**

* Create webhooks with **Signature Verification**.
* To switch an existing webhook, create a new one with Signature Verification, move your receiver over, then delete the old one.

**How Hookdeck Can Help:** Hookdeck's Jamie source verifies `x-jamie-signature`. A request without a valid signature still gets a `200`, but Hookdeck records it as failed verification and never forwards it, so an API key webhook pointed at it shows up straight away in the request log.

### The secret is shown once

**The Problem:** You lose the signing secret and can't look it up.

**Why It Happens:** Jamie displays it only in the creation dialog.

**Workarounds:**

* Copy it into a secrets manager before you close the dialog.
* If you lose it, regenerate it from the webhook settings and update every receiver.

**How Hookdeck Can Help:** With Hookdeck in front, the secret lives on one source, so regenerating it is one change rather than one per service.

### A 5-minute window rejects late deliveries

**The Problem:** Valid deliveries fail verification after an outage.

**Why It Happens:** Jamie's last two retries come 10 minutes and an hour after the first attempt, with the original `t`. A 5-minute window rejects them, and it rejects Hookdeck retries and replays too.

**Workarounds:**

* Enforce the window only when Jamie calls you directly, and make it configurable.
* Deduplicate on `metadata.id`, which protects you against replays whatever the window.

**How Hookdeck Can Help:** Hookdeck verifies the signature when the request arrives and applies no timestamp window. Deliveries from Hookdeck keep their original headers, so keep your own window off behind Hookdeck.

### Five attempts in about an hour

**The Problem:** Meetings processed during a longer outage never reach your systems.

**Why It Happens:** Jamie makes 5 attempts over about 1 hour and 11 minutes, then moves the delivery to a dead-letter queue and emails the owner or the workspace admins. A response slower than 30 seconds counts as a failure.

**Workarounds:**

* Acknowledge within 30 seconds and do the CRM or ticketing work afterwards.
* Act on Jamie's failure emails, which are limited to one an hour per endpoint, so one email can stand for many failures.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives, then delivers to your service with [retries](/docs/retries) you configure. [Issues](/docs/issues) alert you to failing deliveries, and you can replay any meeting.

## Best practices

### Verify against the raw body

Jamie signs the exact bytes it sends. In Express, use `express.raw({ type: '*/*' })` on the route. In Next.js, use `await request.text()`. In FastAPI, use `await request.body()`. Parse only after verification passes.

### Deduplicate on `metadata.id`

`metadata.id`, also sent as `jamie-delivery`, identifies the delivery. Store it before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Use personal webhooks for personal data

Jamie's advice is to use a personal webhook if you only want your own meetings, rather than filtering a workspace webhook on the receiving end. Workspace webhooks carry every colleague's transcripts.

### Handle nulls and large bodies

Use optional access for `endTime`, `data.event.id`, `externalId`, emails and assignees. Allow for request bodies the size of a full transcript.

### Acknowledge fast and process asynchronously

Verify, persist, return `200`, then process. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Jamie sends `meeting.completed` with the full summary, transcript and tasks, signed with `x-jamie-signature: t=...,v0=...`, a hex HMAC-SHA256 of the timestamp and raw body, when you choose Signature Verification. Choose that mode, store the secret the moment it appears, verify the raw bytes, deduplicate on `metadata.id`, keep any timestamp window off behind Hookdeck, and answer within 30 seconds.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Jamie signature, and holds each meeting until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Jamie webhooks reliably in minutes.