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

fieldmeaning
eventCurrently always drop.created. Future events may include drop.updated or drop.deleted — switch on this.
pouch_userThe pouch user id whose stream produced this. Stable across drops.
streamWhich stream the drop landed in (inbox or kept). Useful when one webhook listens to multiple streams.
sent_atRFC 3339 timestamp at the moment pouch dispatched. Use drop.created_at for the actual creation time.
drop.idStable id, prefixed itm-. Use this for dedup against earlier deliveries (see X-Pouch-Delivery below).
drop.bodyPlaintext for now. May be base64 binary in the future for non-text MIMEs.
drop.mimeOptional content-type hint. Empty for plain text drops.

2. Headers

headervalue
Content-Typeapplication/json
User-Agentpouch-webhook/1.0
X-Pouch-DeliveryUUID, unique per delivery attempt. Use for receiver-side dedup if you care about exactly-once.
X-Pouch-SignatureHMAC-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));
}
About the secret. When you create a webhook sink (Settings → Forwarding → Add sink), the optional HMAC secret field is what gets used here. If you leave it blank, pouch sends without the signature header — the URL alone gates access. For anything beyond a personal Zapier hop, set a secret.

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.