Webhooks

mykal posts a JSON event to every registered endpoint when a reminder fires. Delivery is at-least-once, HMAC-SHA256 signed, replay-protected (300s window), and backed off with 4 attempts before the payload is dead-lettered.

Event types

eventwhenshape
reminder.fired a scheduled reminder's remind_at hits task + delivery_id + timestamps
webhook.test the caller hits /webhooks/:id/test endpoint_id + diagnostic message
future task.done, task.overdue, schedule.run_completed placeholder — not yet emitted

Payload envelope

JSON bytes are canonical: keys sorted, no whitespace, UTF-8, UTC Z datetimes. You MUST hash the exact bytes we sent — any pretty-printing by a reverse proxy breaks the signature. See "Gotchas" below.

json
{
  "event": "reminder.fired",
  "delivery_id": "01HZABCDEFG...",
  "occurred_at": "2026-04-18T17:30:00Z",
  "reminder_set_at": "2026-04-18T17:15:00Z",
  "tenant_id": "default",
  "user_id": "01HYUSER...",
  "attempt": 1,
  "task": {
    "id": "01HYTASK...",
    "title": "Draft Q3 roadmap",
    "description": "...",
    "priority": 2,
    "duration_min": 90,
    "chosen_slot_start": "2026-04-18T17:30:00Z",
    "chosen_slot_end":   "2026-04-18T19:00:00Z",
    "tags": ["strategy"]
  }
}

Signing spec

Signing formula (ARCHITECTURE.md §11, D-04): HMAC_SHA256(secret, f"{t}.{raw_body_bytes}") -> hex. The raw per-endpoint secret IS the HMAC key — you can reproduce the signature with only that secret.

headerdescription
X-Mykal-Signaturet=<unix>,v1=<hex>
X-Mykal-Timestampunix seconds at send
X-Mykal-Delivery-Idstable across retries — dedupe on this
X-Mykal-Evente.g. reminder.fired
X-Mykal-Endpoint-Idthe endpoint this delivery targets
User-Agentmykal/1.0 (+)
Content-Typeapplication/json

Replay window is a hard-coded 300 seconds. Reject anything where abs(now - t) > 300.

Verification code

Python

python
import hmac, hashlib, time

def verify(raw_body: bytes, headers: dict, secret: str, window: int = 300) -> bool:
    sig_header = headers.get("X-Mykal-Signature", "")
    parts = dict(p.split("=", 1) for p in sig_header.split(",") if "=" in p)
    try:
        t = int(parts["t"]); v1 = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(int(time.time()) - t) > window:
        return False
    expected = hmac.new(
        secret.encode("utf-8"),
        f"{t}.".encode("ascii") + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, v1)

Node.js

javascript
import crypto from "node:crypto";

export function verify(rawBody, headers, secret, windowSec = 300) {
    const sig = headers["x-mykal-signature"] || "";
    const parts = Object.fromEntries(
        sig.split(",").filter(p => p.includes("=")).map(p => p.split("=", 2))
    );
    const t = Number(parts.t);
    const v1 = parts.v1;
    if (!Number.isFinite(t) || !v1) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - t) > windowSec) return false;

    const expected = crypto
        .createHmac("sha256", secret)
        .update(`${t}.`)
        .update(rawBody)
        .digest("hex");

    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(v1, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Go

go
package mykal

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "math"
    "net/http"
    "strconv"
    "strings"
    "time"
)

func Verify(rawBody []byte, r *http.Request, secret string, windowSec int64) bool {
    sig := r.Header.Get("X-Mykal-Signature")
    var t int64
    var v1 string
    for _, kv := range strings.Split(sig, ",") {
        parts := strings.SplitN(kv, "=", 2)
        if len(parts) != 2 {
            continue
        }
        switch parts[0] {
        case "t":
            t, _ = strconv.ParseInt(parts[1], 10, 64)
        case "v1":
            v1 = parts[1]
        }
    }
    if t == 0 || v1 == "" {
        return false
    }
    if math.Abs(float64(time.Now().Unix()-t)) > float64(windowSec) {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))

    a, _ := hex.DecodeString(expected)
    b, _ := hex.DecodeString(v1)
    return hmac.Equal(a, b)
}

Rust

rust
// Cargo.toml: hmac = "0.12", sha2 = "0.10", subtle = "2", hex = "0.4"
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};

type H = Hmac<Sha256>;

pub fn verify(raw_body: &[u8], sig_header: &str, secret: &str, window_sec: i64) -> bool {
    let mut t: i64 = 0;
    let mut v1: &str = "";
    for part in sig_header.split(',') {
        if let Some((k, v)) = part.split_once('=') {
            match k.trim() {
                "t"  => t  = v.trim().parse().unwrap_or(0),
                "v1" => v1 = v.trim(),
                _    => {}
            }
        }
    }
    if t == 0 || v1.is_empty() { return false; }
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    if (now - t).abs() > window_sec { return false; }

    let mut mac = H::new_from_slice(secret.as_bytes()).expect("hmac key");
    mac.update(format!("{}.", t).as_bytes());
    mac.update(raw_body);
    let expected = hex::encode(mac.finalize().into_bytes());

    // constant-time compare
    subtle::ConstantTimeEq::ct_eq(expected.as_bytes(), v1.as_bytes()).into()
}

Ruby

ruby
require "openssl"
require "time"

def verify(raw_body, headers, secret, window = 300)
  sig = headers["X-Mykal-Signature"] || headers["x-mykal-signature"] || ""
  parts = sig.split(",").each_with_object({}) do |p, h|
    k, v = p.split("=", 2)
    h[k] = v if k && v
  end
  t  = parts["t"].to_i
  v1 = parts["v1"] || ""
  return false if t.zero? || v1.empty?
  return false if (Time.now.to_i - t).abs > window

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.".b + raw_body)
  OpenSSL.fixed_length_secure_compare(expected, v1)
rescue ArgumentError
  false
end

PHP

php
<?php
function mykal_verify(string $rawBody, array $headers, string $secret, int $window = 300): bool {
    $sig = $headers['X-Mykal-Signature'] ?? $headers['x-mykal-signature'] ?? '';
    $parts = [];
    foreach (explode(',', $sig) as $chunk) {
        if (!str_contains($chunk, '=')) continue;
        [$k, $v] = explode('=', $chunk, 2);
        $parts[trim($k)] = trim($v);
    }
    $t  = isset($parts['t'])  ? (int)$parts['t']  : 0;
    $v1 = $parts['v1'] ?? '';
    if ($t === 0 || $v1 === '') return false;
    if (abs(time() - $t) > $window) return false;

    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $v1);
}

Retries & DLQ

Default schedule (DECISIONS.md D-08): 5s → 30s → 5min → 1h → DLQ. Per-endpoint max_attempts lives in [0, 8]; higher ceilings fill from the 6h / 12h / 24h tail.

statusclassification
2xxsent
429retry
5xxretry
400, 401, 403, 404, 405, 406, 410, 413, 414, 415, 422hard-fail → DLQ
other 4xxhard-fail → DLQ
3xxfollowed once, then re-classified

After max_attempts exhausted the delivery is moved to mykal.reminder_dlq. Inspect via GET reminders/dlq; requeue one via POST reminders/dlq/{id}/requeue.

Secret rotation

  1. Call POST webhooks/{id}/rotate-secret.
  2. Save the new plaintext. The old secret is invalidated for all new deliveries.
  3. Deploy your receiver so it accepts both secrets for a brief overlap window (recommended 5 min) to avoid losing in-flight deliveries that are already signed with the old key but retrying.
  4. Drop support for the old secret.

Store the secret in your secrets manager — never in source. mykal never displays the full secret again; only the preview (abcd...wxyz) is ever returned after creation.

Testing

Fire a synthetic webhook.test payload at your endpoint without consuming the retry budget:

bash
curl -X POST "$MYKAL_BASE/api/v1/mykal/webhooks/$ENDPOINT_ID/test" \
    -H "Authorization: Bearer $MYKAL_TOKEN"
# { "ok": true, "status_code": 200, "latency_ms": 187,
#   "signature": "t=1713462900,v1=...", "response_preview": "ok" }

The test payload uses the exact same signing path as reminder.fired, so a green test proves your verifier works end-to-end.

Gotchas