Webhook reference
When you attach a webhook sink to a pouch stream, every drop landing in that stream gets POSTed as JSON to the URL you configured. This page is the wire-level contract: payload shape, signing, headers, retries, and a 30-line receiver you can copy.
1. The payload
One POST per drop. Content-Type: application/json.
Stable shape — pouch only adds fields, never removes or renames.
{
"event": "drop.created",
"pouch_user": "jy",
"stream": "inbox",
"sent_at": "2026-04-25T19:34:21+08:00",
"drop": {
"id": "itm-4296ec10-790",
"label": "weekly review notes",
"body": "...",
"tags": ["planning", "cli"],
"mime": "text/markdown",
"source": "cli",
"created_at": "2026-04-25T19:33:00+08:00"
}
}
Fields
| field | meaning |
|---|---|
| event | Currently always drop.created. Future events may include drop.updated or drop.deleted — switch on this. |
| pouch_user | The pouch user id whose stream produced this. Stable across drops. |
| stream | Which stream the drop landed in (inbox or kept). Useful when one webhook listens to multiple streams. |
| sent_at | RFC 3339 timestamp at the moment pouch dispatched. Use drop.created_at for the actual creation time. |
| drop.id | Stable id, prefixed itm-. Use this for dedup against earlier deliveries (see X-Pouch-Delivery below). |
| drop.body | Plaintext for now. May be base64 binary in the future for non-text MIMEs. |
| drop.mime | Optional content-type hint. Empty for plain text drops. |
2. Headers
| header | value |
|---|---|
| Content-Type | application/json |
| User-Agent | pouch-webhook/1.0 |
| X-Pouch-Delivery | UUID, unique per delivery attempt. Use for receiver-side dedup if you care about exactly-once. |
| X-Pouch-Signature | HMAC-SHA256 of the request body, prefixed sha256=. Only set when the sink has a shared secret. Verify this — see §3. |
3. Verifying the signature
The signature is HMAC-SHA256 of the raw request body, keyed with the shared secret you set when creating the sink. Compare in constant time and reject if it doesn't match.
// Standard library only.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strings"
)
func verify(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := strings.TrimPrefix(r.Header.Get("X-Pouch-Signature"), "sha256=")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(want)) {
http.Error(w, "bad signature", 401)
return
}
// Body is verified. Parse + handle.
}
}
import hmac, hashlib
def verify(body: bytes, header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
sig = header.removeprefix("sha256=")
return hmac.compare_digest(sig, expected)
const crypto = require('crypto');
function verify(body, header, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
const sig = (header || '').replace(/^sha256=/, '');
// Buffers must be equal length for timingSafeEqual.
if (sig.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
4. Retries and idempotency
Pouch tries to deliver each drop once, inline. If your endpoint returns a non-2xx or times out (5s), pouch re-schedules with geometric backoff: 30s, 2m, 8m. After 4 total attempts the delivery is marked failed and shows up in the Outbox audit view.
Be idempotent. A successful delivery your endpoint failed to acknowledge
(e.g. crashed mid-write) gets re-sent. Same X-Pouch-Delivery
UUID? Same drop. Use it as a dedup key.
5. A complete receiver — 30 lines
This is what the
pouch-recipes
repo's "git-commit-per-drop" example boils down to. Drop into a
main.go and you've got a working vault.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strings"
)
type drop struct {
ID, Label, Body string
}
type payload struct {
Drop drop `json:"drop"`
}
func main() {
secret := os.Getenv("POUCH_SECRET")
http.HandleFunc("/hook", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := strings.TrimPrefix(r.Header.Get("X-Pouch-Signature"), "sha256=")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
if !hmac.Equal([]byte(sig), []byte(hex.EncodeToString(mac.Sum(nil)))) {
http.Error(w, "bad signature", 401); return
}
var p payload
json.Unmarshal(body, &p)
log.Printf("drop %s: %s", p.Drop.ID, p.Drop.Label)
w.WriteHeader(200)
})
log.Fatal(http.ListenAndServe(":7777", nil))
}
6. What's next
Pouch deliveries today only fan out drop-creation events. Drop edits and deletions
will eventually emit too (drop.updated / drop.deleted) — pin to
event == "drop.created" if you want to ignore them when they land.
Looking for ready-made receivers? The pouch-recipes repo has examples for filing drops into git, NDJSON, and SQLite. The pouch-vault daemon ships a SQLite-backed receiver that already does this end-to-end with heartbeats — pre-built binaries for Linux, macOS, Windows.