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
| event | when | shape |
|---|---|---|
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.
{
"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.
| header | description |
|---|---|
X-Mykal-Signature | t=<unix>,v1=<hex> |
X-Mykal-Timestamp | unix seconds at send |
X-Mykal-Delivery-Id | stable across retries — dedupe on this |
X-Mykal-Event | e.g. reminder.fired |
X-Mykal-Endpoint-Id | the endpoint this delivery targets |
User-Agent | mykal/1.0 (+) |
| Content-Type | application/json |
Replay window is a hard-coded 300 seconds. Reject anything
where abs(now - t) > 300.
Verification code
Python
pythonimport 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
javascriptimport 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
gopackage 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
rubyrequire "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.
| status | classification |
|---|---|
| 2xx | sent |
| 429 | retry |
| 5xx | retry |
| 400, 401, 403, 404, 405, 406, 410, 413, 414, 415, 422 | hard-fail → DLQ |
| other 4xx | hard-fail → DLQ |
| 3xx | followed 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
- Call
POST webhooks/{id}/rotate-secret. - Save the new plaintext. The old secret is invalidated for all new deliveries.
- 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.
- 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:
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
- Body mutation: some reverse proxies re-serialize JSON (e.g. Cloudflare workers with
request.json()then re-stringify). Sign/verify on the raw bytes before any middleware parses. - Clock skew: keep your server in NTP sync. The replay window is 300s — more than enough for geographic skew, not enough for a broken clock.
- Dedupe by delivery_id: retries re-use
X-Mykal-Delivery-Id; dedupe on that, not on any hash of the body (retries are byte-identical). - Redirects: we follow exactly one HTTP redirect and re-POST. If your endpoint 301s to an auth wall, every delivery dies on the hop.
- Body cap: 256 KiB default (
MYKAL_WEBHOOK_MAX_BODY_KB). Oversized payloads → dead-lettered. - Timeout: 10s default (
MYKAL_WEBHOOK_TIMEOUT_S). Respond fast — queue the work, 200 OK, process async.