Webhooks
Updated
Webhooks is coming in Wisply 1.11. Check the App Store for the version on your device.
Overview
Wisply can post a finished memo that has a transcript as JSON to a URL you choose, typically a Zapier, Make or n8n "catch hook" that passes it on to Linear, Jira, Asana, ClickUp, Google Docs, Notion, a CRM, or anything else those services reach. Your own HTTPS server works too.
- Off by default. Nothing is sent until you paste a URL and turn it on.
- Device to your URL only. The request goes directly from your iPhone, iPad or Mac to the URL you entered. There's no Wisply server in between, so Wisply's servers never see the event.
- Signed. Every request carries Standard Webhooks (opens in a new tab) headers, so a receiver can check it came from your device and wasn't changed.
- HTTPS only.
http://URLs, URLs with a user name or password, and redirects are refused. - The URL and the signing secret are stored in your device's Keychain.
Turn it on
- Open Settings → Integrations (on iPhone and iPad: Settings → Integrations → Webhooks).
- Paste your webhook URL. An inline message explains anything it rejects.
- Optional: turn off Include full transcript to send the title, summary, action items and questions only.
- Tap Send test event. The status line shows the HTTP result.
- Turn on Send finished recordings to a webhook (Send finished memos on iPhone and iPad).
From then on, each recording, import (an audio or video file, or text you paste on iPhone or iPad) and Apple Watch memo that has a transcript is sent after its title is generated and, when that's on, its Obsidian note is written. A pasted-text memo's transcript is the text you pasted, so that text is sent in transcript and markdown unless Include full transcript is off; its title, which can be the text's first line, and its AI summary are sent either way. A memo with no transcript isn't sent.
If the memo has no summary yet, Wisply first writes one on your device, under the same Wisply Pro and free-allowance rules as the Summary tab.
Delivery is at-least-once: if the app stops after the receiver accepted the event and before Wisply recorded that success, the same memo can be sent again. Receivers should dedupe on the event id, which is the memo id.
Events
| Type | When |
|---|---|
memo.completed | A recording, import (file or pasted text) or Apple Watch memo finished and was saved. |
webhook.test | You tapped Send test event. The data is a sample memo with every field filled in, so Zapier, Make and n8n can learn the fields before your first real memo. |
{
"type": "memo.completed",
"id": "9C1B6A0E-5D1F-4E0B-8F0A-6B2D3C4E5F60",
"timestamp": "2026-10-19T16:20:05Z",
"api_version": "2026-10-01",
"data": {
"memo_id": "9C1B6A0E-5D1F-4E0B-8F0A-6B2D3C4E5F60",
"title": "Launch review",
"created_at": "2026-10-19T15:58:41Z",
"duration_seconds": 1284.5,
"language_code": "en",
"summary": "The team approved the phased launch…",
"action_items": [
{
"owner": "Alex",
"task": "Publish the beta build",
"due_date": "Friday"
}
],
"open_questions": [
{
"asked_by": "Sam",
"question": "Who owns support during launch week?",
"answered": false
}
],
"speakers": [
"Alex",
"Sam"
],
"transcript": "…",
"markdown": "# Launch review\n\n*Recorded …*\n\n## Summary\n\n…",
"source": "wisply",
"app_version": "1.11.0"
}
}| Field | Notes |
|---|---|
type | memo.completed or webhook.test. |
id | For memo.completed, the memo id. Delivery is at-least-once, so receivers should dedupe on this id. A test event uses a new id. Also sent as the webhook-id header and unchanged across the retries of one send. |
timestamp | When the event was created. ISO 8601, UTC. |
api_version | Changes only when a field is removed or changes meaning. New fields can be added at any time. |
data | The memo. |
data.memo_id | The memo's id. |
data.title | The memo's title. |
data.created_at | When the memo was created. ISO 8601, UTC. |
data.duration_seconds | Length of the recording in seconds. |
data.language_code | Absent when unknown. |
data.summary | Absent when the memo has no summary (for example, Apple Intelligence is unavailable). Also absent when Include full transcript is off and the only summary is transcript excerpts. |
data.action_items | AI action items, possibly empty. |
data.action_items[].owner | Who the item is for, as the summary names them. Can be empty. |
data.action_items[].task | What needs doing. |
data.action_items[].due_date | Absent when no due date was mentioned. |
data.open_questions | Questions raised in the recording, possibly empty. |
data.open_questions[].asked_by | Who asked, as the summary names them. |
data.open_questions[].question | The question. |
data.open_questions[].answered | Whether the recording answered it. |
data.speakers | Display names in order of first appearance; empty without speaker identification. |
data.transcript | Plain text. Omitted when Include full transcript is off. |
data.markdown | The Obsidian note without YAML frontmatter. The transcript section, the standout quote and an excerpt-based summary are left out when Include full transcript is off. |
data.source | Always wisply. |
data.app_version | The Wisply version that sent the event. |
Audio is never sent.
Requests, retries and timeouts
Each delivery is a POST with a 30-second timeout and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Wisply/<version> |
webhook-id | The event id. |
webhook-timestamp | Unix seconds of this attempt. |
webhook-signature | v1,<base64 HMAC-SHA256>, signed with the secret current at that attempt. |
- Any 2xx response is success.
- After a network error, a
429or a5xx, Wisply tries again, up to 3 attempts in all, waiting 2 seconds and then 8 seconds. - Any other status, including a
3xxredirect, is final. Redirects are never followed. - Each attempt is signed with the secret current at that moment.
- A failed delivery is shown in Settings and isn't queued. The same memo can be sent again later, with the same event id.
Before each attempt, a finished memo is sent only if webhooks are still on, and only to the URL configured at that moment. An event that includes a transcript is sent only if Include full transcript is still on. Turning either off, or changing the URL, drops the remaining attempts, and Wisply asks the request in flight to stop, checking at least every half second. That can't recall a request the endpoint has already received, so a request that was already sent may still arrive.
After a 2xx, Wisply remembers the delivered memo in a bounded list, so a later launch usually doesn't send it again; a crash between the 2xx and that write sends it again. Treat delivery as at-least-once and dedupe on the event id.
Wisply reads at most 64 KB of the response body and uses only the status code.
Verify the signature
The signature is HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<raw body>. The key is your signing secret (copy it from Signing secret in Settings) with the whsec_ prefix removed, base64-decoded. Compare against the raw request body, before any JSON parsing, and reject timestamps more than a few minutes old.
Regenerating the secret replaces it, so update your receiver afterwards. If Wisply can't read or save the secret in the Keychain, the previous secret stays in place: the delivery fails, or Settings shows the error next to Regenerate.
The Standard Webhooks (opens in a new tab) reference libraries verify these headers directly. Or use one of these:
const crypto = require("crypto");
function verifyWisplyWebhook(secret, headers, rawBody, toleranceSeconds = 300) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
const sentAt = Number(timestamp);
if (!Number.isFinite(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > toleranceSeconds) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(
crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64")
);
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
// Compare bytes, not characters: a non-ASCII header has more bytes than characters.
const received = Buffer.from(signature);
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}import base64, hashlib, hmac, time
def verify_wisply_webhook(secret: str, headers: dict, raw_body: bytes, tolerance_seconds: int = 300) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures):
return False
try:
sent_at = int(timestamp)
except ValueError:
return False
if abs(time.time() - sent_at) > tolerance_seconds:
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())
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
# Compare bytes: compare_digest raises on non-ASCII str.
if version == "v1" and hmac.compare_digest(signature.encode(), expected):
return True
return FalseWith the timestamp check turned off (the vector is from 2021), both snippets accept the published Standard Webhooks test vector:
| Input | Value |
|---|---|
| Secret | whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw |
webhook-id | msg_p5jXN8AQM9LWM0D4loKWxJek |
webhook-timestamp | 1614265330 |
| Body | {"test": 2432232314} |
webhook-signature | v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
Connect a service
In each service, start with a webhook trigger, send a test event from Wisply so the service learns the fields, then map them into your apps.
Zapier
- Create a Zap with the trigger Webhooks by Zapier → Catch Hook and copy its URL (
https://hooks.zapier.com/hooks/catch/…). - Paste it into Wisply and tap Send test event; in Zapier, click Test trigger to load the sample memo.
- Add actions such as Linear → Create Issue or Google Docs → Create Document from Text, map
data.title,data.summary,data.markdownordata.action_items, publish the Zap, and turn on webhooks in Wisply.
Make
- Create a scenario that starts with Webhooks → Custom webhook, add a webhook and copy its address (
https://hook.<region>.make.com/…). - Paste it into Wisply and tap Send test event while the module is listening, so Make learns the data structure.
- Add modules (Jira, Asana, Google Docs and others), map the fields, and turn the scenario and Wisply webhooks on. Use an Iterator on
data.action_itemsto create one task per item.
n8n
- Add a Webhook node with HTTP method POST and copy its Production URL. A self-hosted n8n instance must be reachable over HTTPS.
- Paste it into Wisply. To capture the sample, switch the node to its Test URL, click Listen for test event, and tap Send test event; switch back to the production URL afterwards.
- Optionally add a Code node that checks
webhook-signature(see Verify the signature), then connect the nodes for your apps and activate the workflow.
What's sent and what isn't
- Sent: the memo id, title, date, duration, language, speaker names, AI summary, action items, open questions, a Markdown version, the Wisply version and, while Include full transcript is on, the full transcript.
- Never sent: audio, and your signing secret, which signs each request but never leaves your device.
- Sent directly from your device to the URL you entered. No Wisply server is in between.
- What happens to a memo after delivery is up to the receiver and its own terms.
Turn it off
Turn off Send finished recordings to a webhook (Send finished memos on iPhone and iPad), or clear the URL. Wisply stops sending at once and keeps nothing for the receiver; a request already in flight may still arrive. Memos already delivered stay wherever the receiver stored them, so delete them there.
Versioning
Every event carries api_version. The current version, and the first, is 2026-10-01.
It changes only when a field is removed or changes meaning. New fields can be added at any time, so ignore fields you don't recognize.