MoonHook

    Outbound webhook delivery framework for MoonBit: signing, retry with backoff, outbox, dead letter, idempotency, audit, and real HTTP delivery over moonbitlang/async.

    webhook
    delivery
    http
    hmac
    retry
    outbox
    idempotency
    audit
    moonbit
    Download zip
    Version
    0.4.0
    License
    Apache-2.0
    Last updated
    21 hours ago
    Downloads
    7

    Dependencies

    #MoonHook

    CI

    WebHook 投递(出站)框架,使用 MoonBit 编写:把事件可靠、可验证地送到接收端, 并记录每一次尝试。核心能力包括事件建模、Hook 注册、HMAC-SHA256 签名与验签、 时间戳防重放、重试策略、投递引擎、Outbox、死信队列、幂等去重和审计日志。 核心代码为纯 MoonBit 实现,不依赖 FFI,也不依赖第三方密码学库。

    在此之上,transport_http 包基于 moonbitlang/async 提供真实 HTTP 投递 签名请求会真正 POST 到接收端,重试会按退避策略真实等待,接收端可以直接用 webhook_receiver 挂到 @http.Server 上完成验签。

    本项目专注投递侧:入站只提供最小验签处理器,不做路由、中间件与平台入站适配。 功能边界、与生态中相邻项目的关系见 docs/SCOPE.md

    #工程验证

    moon check --deny-warn # 三个目标均 0 warning moon test --deny-warn # 75 个测试 moon check --target wasm-gc --deny-warn # 核心包保持可移植 moon check --target js --deny-warn moon test --enable-coverage # 覆盖率:库代码 90% 以上(不含 CLI) moon coverage report -f summary moon run cmd/httpdemo -- e2e # 真实 HTTP 投递 + 验签,端到端

    CI 在 native / wasm-gc / js 三个目标上执行 checktest,并校验 moon fmt --checkmoon info 生成的接口与仓库一致,最后跑一遍 e2e 冒烟测试。

    #核心功能

    • WebhookEvent / Hook 数据模型与校验式 Builder
    • HookRegistry 注册表:注册、查询、去重、校验
    • 纯 MoonBit SHA-256,通过标准测试向量
    • HMAC-SHA256 签名与常量时间验签
    • 时间戳签名 sign_webhook / verify_webhook,支持防重放窗口
    • 接收端验签:大小写不敏感 header、sha256= 前缀兼容
    • RetryPolicy:不重试、固定间隔、指数退避,参数校验
    • DeliveryEngine:首投成功、重试后成功、耗尽后死信、非可重试失败、跳过
    • InMemoryOutbox / InMemoryDeadLetter:待投递队列与死信队列
    • IdempotencyStore:TTL 幂等去重与过期清理
    • AuditLog:投递尝试审计与最近记录查询
    • Hook / 待投递 / 死信 JSON 序列化与反序列化
    • SignatureProvider:出站平台签名适配,覆盖 GitHub、Stripe、Slack、飞书 自定义机器人、Shopify,收发两侧共用同一套实现
    • transport_http:基于 moonbitlang/async/http 的真实 HTTP 投递 post_signed_requesthttp_transport
    • AsyncDeliveryEngine:异步投递引擎,按 RetryPolicy::next_delay_ms 在两次尝试之间真正 sleep,而不是背靠背重试
    • webhook_receiver:接收端处理器,读 body、验签、校验时间戳窗口并回 200/401
    • 演示 CLI:内存流水线、签名、验签、请求构建、平台签名,以及 HTTP 收发演示

    #快速开始

    moon test # 核心测试 + HTTP 集成测试 moon run cmd/main -- demo # 内存流水线演示 moon run cmd/main -- sign <secret> <payload> moon run cmd/main -- verify <secret> <payload> <signature> moon run cmd/main -- request <hook-id> <url> <secret> <payload> moon run cmd/httpdemo -- e2e # 起接收端 + 真实投递 + 验签,一条命令跑通 moon run cmd/httpdemo -- serve 127.0.0.1:8080 <secret> moon run cmd/httpdemo -- deliver <url> <secret> <payload>

    demo 命令会在内存中完成“注册 Hook -> 构建事件 -> 签名 -> 投递 -> 审计” 的完整流程;request 命令会打印一个带签名和时间戳 header 的 HTTP 请求。

    e2e 命令在同一个进程里启动 MoonHook 接收端,再通过 HTTP 投递一个签名事件, 并把收发两侧的结果都打印出来:

    [receiver] listening on http://127.0.0.1:18080/hooks/orders [sender] POST http://127.0.0.1:18080/hooks/orders [sender] attempt 1: HTTP 200 in 2ms [sender] status: success [receiver] verified payloads: 1 [receiver] payload: {"order_id": "A-1001", "total": 99}

    #真实 HTTP 投递

    发送端把 HookWebhookEvent 交给 AsyncDeliveryEngine,每次尝试都会通过 http_transport() 真正发出请求:

    ///|
    async fn main {
    let registry = @MoonHook.HookRegistry::new()
    let hook = @MoonHook.Hook::new("orders", "https://example.com/hooks/orders")
    .with_secret("shared-secret")
    .with_retry_policy(@MoonHook.RetryPolicy::Exponential(200, 2.0, 5_000))
    .with_max_attempts(5)
    registry.register(hook) |> ignore
    let engine = @transport_http.AsyncDeliveryEngine::new(
    registry,
    @transport_http.http_transport(),
    )
    let outcome = engine.deliver("orders", event)
    println(outcome.status.describe())
    }

    接收端把 webhook_receiver 直接挂到 @http.Server 上,验签和时间戳窗口由 MoonHook 负责:

    ///|
    async fn main {
    let server = @http.Server(@socket.Addr::parse("127.0.0.1:8080"))
    server.run_forever(
    @transport_http.webhook_receiver("shared-secret", 300_000, payload => {
    println("verified webhook: \{payload}")
    }),
    )
    }

    重试语义:2xx 视为成功;408、425、429 与 5xx 视为可重试失败;其他 4xx 直接进入 死信;连接层错误(DNS、拒绝连接等)同样可重试。每次尝试的状态码与耗时都会记录 DeliveryOutcome::attempts 中。

    #目标平台

    • 核心包(事件、签名、重试、引擎、存储)保持平台无关,CI 额外验证 wasm-gc jsDeliveryEngine 在这些目标上照常可用。
    • transport_http 依赖 moonbitlang/async 的原生 IO,因此声明 supported_targets = "+native",只在 native 目标上参与构建。

    #平台签名适配(出站)

    不同平台的 WebHook 签名方案互不兼容。SignatureProvider 把主流方案收敛成一套 API:发送端用它生成请求头,接收端用同一个 API 验签。

    平台方案签名内容
    GitHubX-Hub-Signature-256: sha256=<hex>原始 body
    StripeStripe-Signature: t=<unix_s>,v1=<hex>"<t>.<body>",支持密钥轮换时的多个 v1
    SlackX-Slack-Signature: v0=<hex> + X-Slack-Request-Timestamp"v0:<t>:<body>"
    飞书自定义机器人body 内 timestamp + sign(base64)HMAC-SHA256(key = "<t>\n<secret>", message = "")
    ShopifyX-Shopify-Hmac-Sha256: <base64>原始 body

    ///|
    let provider = @MoonHook.SignatureProvider::Stripe
    // 发送端:把平台签名头追加到已签名的请求上

    ///|
    let signed = request.with_provider_signature(provider, secret, timestamp_s)
    // 接收端:同一个 provider 负责验签,并校验时间窗

    ///|
    let accepted = provider.verify(secret, headers, body, now_s, 300)

    不带时间戳的方案(GitHub、Shopify)只校验签名;带时间戳的方案会额外检查 tolerance_s 窗口,传负值可关闭窗口检查。飞书机器人的签名不覆盖消息正文 它证明发送方持有 secret 且时间戳新鲜,正文完整性还需要应用层校验。

    命令行可以直接看到结果:

    moon run cmd/main -- providers moon run cmd/main -- sign-provider stripe whsec_test_secret 1492774577 '{"id":"evt_1"}'

    测试向量:GitHub 使用官方文档示例(It's a Secret to Everybody / Hello, World!), 其余平台的期望值由独立 HMAC-SHA256 实现交叉验证。

    #收发两侧验签

    发送方用 build_signed_request 构建请求,签名覆盖 "<timestamp_ms>.<payload>",并携带 X-MoonHook-Timestamp

    ///|
    let request = @MoonHook.build_signed_request(hook, event)

    接收方用同一个 secret 校验签名和时间窗口:

    ///|
    let valid = @MoonHook.verify_webhook_request_with_timestamp(
    secret, headers, body, now_ms, 300_000,
    )

    如果接入 GitHub 等外部 WebHook,签名通常是原始 body 的 sha256=<hex> 形式,可用 verify_webhook_request 直接校验:

    ///|
    let valid = @MoonHook.verify_webhook_request(secret, headers, body)

    #代码结构

    event.mbt # 事件模型与 EventBuilder hook.mbt # Hook 模型与配置 Builder registry.mbt # Hook 注册表 retry.mbt # 重试策略、退避计算与参数校验 sha256.mbt # 纯 MoonBit SHA-256 hmac.mbt # HMAC-SHA256 签名与验签 transport.mbt # 投递结果与 Transport 抽象 engine.mbt # DeliveryEngine 投递引擎 store.mbt # Outbox 与死信队列 idempotency.mbt # 幂等键存储 audit.mbt # 审计日志 request.mbt # 签名请求构建与接收端验签 json_codec.mbt # 领域模型 JSON 序列化 coordinator.mbt # MoonHook 高层门面 transport_http/ # 真实 HTTP 投递适配(native):传输、异步引擎、接收端处理器 cmd/main/ # 内存演示 CLI cmd/httpdemo/ # HTTP 收发演示 CLI(native)

    #设计说明

    投递链路以 Transport 函数为边界,HTTP、测试替身或进程内 Sink 都可以接入:

    ///|
    pub type Transport = (Hook, WebhookEvent) -> DeliveryResult

    DeliveryEngine 根据 Hook 的 RetryPolicymax_attempts 决定继续重试 还是进入死信。同步引擎背靠背执行尝试,退避延迟由 RetryPolicy::next_delay_ms 提供给外部调度器;需要真实网络投递时改用 transport_http 中的 AsyncDeliveryEngine,它会等待每次 HTTP 尝试完成,并在 两次尝试之间按同一个策略真正 sleep。

    接收端与发送端共用一套验签实现:build_signed_request 签名 "<timestamp_ms>.<payload>"webhook_receiver 用同一个 secret 校验签名和 时间窗口,因此“自己发、自己收”的端到端演示和接入外部系统走的是同一条代码路径。

    #Roadmap

    #License

    Apache-2.0

    Transport

    type Transport = (Hook, WebhookEvent) -> DeliveryResult

    A transport is a function that performs one delivery attempt. HTTP adapters, test doubles, and in-process sinks all fit this shape.

    AuditEntry

    pub(all) struct AuditEntry {
    timestamp_ms : Int
    hook_id : String
    event_id : String
    attempt : Int
    status : String
    error : String
    duration_ms : Int
    } derive(Eq,
    Debug
    )

    One auditable delivery attempt.

    AuditLog

    pub struct AuditLog {
    entries : Array[AuditEntry]
    } derive(
    Debug
    )

    In-memory audit trail with a bounded recent-view helper.

    AuditLog::length

    fn AuditLog::length(self : AuditLog) -> Int

    AuditLog::list

    fn AuditLog::list(self : AuditLog) -> Array[AuditEntry]

    AuditLog::new

    fn AuditLog::new() -> AuditLog

    AuditLog::recent

    fn AuditLog::recent(self : AuditLog, n : Int) -> Array[AuditEntry]

    Returns the most recent n entries, or all entries when fewer exist.

    AuditLog::record

    fn AuditLog::record(self : AuditLog, entry : AuditEntry) -> Unit

    DeadLetterEntry

    pub(all) struct DeadLetterEntry {
    hook_id : String
    event : WebhookEvent
    reason : String
    failed_at_ms : Int
    attempts : Int
    } derive(Eq,
    Debug
    )

    A delivery that permanently failed and was moved to the dead letter queue.

    DeliveryAttempt

    pub(all) struct DeliveryAttempt {
    attempt : Int
    status_code : Int
    error : String
    duration_ms : Int
    } derive(Eq,
    Debug
    )

    A single recorded attempt inside a delivery.

    DeliveryEngine

    pub struct DeliveryEngine {
    registry : HookRegistry
    transport : (Hook, WebhookEvent) -> DeliveryResult
    }

    Drives delivery: looks up the Hook, executes transport attempts, and applies the Hook's retry policy.

    DeliveryEngine::deliver

    fn DeliveryEngine::deliver(self : DeliveryEngine, hook_id : String, event : WebhookEvent) -> DeliveryOutcome

    Delivers synchronously. Retry delays are exposed through RetryPolicy for external schedulers; this engine performs the attempts back to back and honors RetryPolicy::should_retry before starting another attempt.

    DeliveryEngine::new

    fn DeliveryEngine::new(registry : HookRegistry, transport : (Hook, WebhookEvent) -> DeliveryResult) -> DeliveryEngine

    DeliveryOutcome

    pub(all) struct DeliveryOutcome {
    hook_id : String
    event_id : String
    status : DeliveryStatus
    attempts : Array[DeliveryAttempt]
    } derive(Eq,
    Debug
    )

    The complete outcome of DeliveryEngine::deliver.

    DeliveryResult

    pub(all) struct DeliveryResult {
    ok : Bool
    retryable : Bool
    status_code : Int
    error : String
    duration_ms : Int
    } derive(Eq,
    Debug
    )

    The outcome of one physical delivery attempt.

    DeliveryResult::failure

    fn DeliveryResult::failure(status_code : Int, error : String, duration_ms : Int, retryable : Bool) -> DeliveryResult

    DeliveryResult::success

    fn DeliveryResult::success(duration_ms : Int) -> DeliveryResult

    DeliveryResult::success_with_status

    fn DeliveryResult::success_with_status(status_code : Int, duration_ms : Int) -> DeliveryResult

    A successful attempt that records the status code reported by the receiver, so transports can distinguish 200 from 202 or 204.

    DeliveryStatus

    pub(all) enum DeliveryStatus {
    Success
    Retry
    DeadLetter
    Skipped
    } derive(Eq,
    Debug
    )

    Final state of a delivery after all attempts.

    Retry is reserved for scheduler-driven delivery; the synchronous DeliveryEngine::deliver reports Success, DeadLetter, or Skipped.

    DeliveryStatus::describe

    fn DeliveryStatus::describe(self : DeliveryStatus) -> String

    EventBuilder

    pub(all) struct EventBuilder {
    id : String
    event_type : String
    subject : String
    payload : String
    timestamp_ms : Int
    idempotency_key : String
    } derive(Eq,
    Debug
    )

    Builder for WebhookEvent with validation in build.

    EventBuilder::build

    fn EventBuilder::build(self : EventBuilder) -> Result[WebhookEvent, String]

    Validates required fields and falls back to a deterministic idempotency key based on event id and type.

    EventBuilder::new

    EventBuilder::with_event_type

    fn EventBuilder::with_event_type(self : EventBuilder, event_type : String) -> EventBuilder

    EventBuilder::with_id

    fn EventBuilder::with_id(self : EventBuilder, id : String) -> EventBuilder

    EventBuilder::with_idempotency_key

    fn EventBuilder::with_idempotency_key(self : EventBuilder, idempotency_key : String) -> EventBuilder

    EventBuilder::with_payload

    fn EventBuilder::with_payload(self : EventBuilder, payload : String) -> EventBuilder

    EventBuilder::with_subject

    fn EventBuilder::with_subject(self : EventBuilder, subject : String) -> EventBuilder

    EventBuilder::with_timestamp_ms

    fn EventBuilder::with_timestamp_ms(self : EventBuilder, timestamp_ms : Int) -> EventBuilder

    Hook

    pub(all) struct Hook {
    id : String
    url : String
    secret : String
    headers : Array[(String, String)]
    active : Bool
    max_attempts : Int
    retry_policy : RetryPolicy
    timeout_ms : Int
    } derive(Eq,
    Debug
    )

    A Hook describes one webhook destination and its delivery policy.

    Hook::header_value

    fn Hook::header_value(self : Hook, name : String) -> String?

    Looks up a header value by name, returning the last match. Header names are matched case-insensitively, following HTTP semantics.

    Hook::new

    fn Hook::new(id : String, url : String) -> Hook

    Creates a Hook with safe defaults: active, three attempts, fixed 100ms retry delay, and a 5 second timeout.

    Hook::validate

    fn Hook::validate(self : Hook) -> Result[Unit, String]

    Hook::with_active

    fn Hook::with_active(self : Hook, active : Bool) -> Hook

    Hook::with_header

    fn Hook::with_header(self : Hook, name : String, value : String) -> Hook

    Hook::with_max_attempts

    fn Hook::with_max_attempts(self : Hook, max_attempts : Int) -> Hook

    Hook::with_retry_policy

    fn Hook::with_retry_policy(self : Hook, retry_policy : RetryPolicy) -> Hook

    Hook::with_secret

    fn Hook::with_secret(self : Hook, secret : String) -> Hook

    Hook::with_timeout_ms

    fn Hook::with_timeout_ms(self : Hook, timeout_ms : Int) -> Hook

    HookRegistry

    pub struct HookRegistry {
    hooks :
    HashMap
    [String, Hook]
    } derive(
    Debug
    )

    An in-memory registry of registered webhook destinations.

    HookRegistry::contains

    fn HookRegistry::contains(self : HookRegistry, id : String) -> Bool

    HookRegistry::count

    fn HookRegistry::count(self : HookRegistry) -> Int

    HookRegistry::get

    fn HookRegistry::get(self : HookRegistry, id : String) -> Hook?

    HookRegistry::list

    fn HookRegistry::list(self : HookRegistry) -> Array[Hook]

    HookRegistry::new

    HookRegistry::register

    fn HookRegistry::register(self : HookRegistry, hook : Hook) -> Result[Unit, String]

    Registers a validated Hook. Duplicate ids are rejected.

    HookRegistry::unregister

    fn HookRegistry::unregister(self : HookRegistry, id : String) -> Unit

    IdempotencyRecord

    pub(all) struct IdempotencyRecord {
    event_id : String
    first_seen_ms : Int
    expires_at_ms : Int
    } derive(Eq,
    Debug
    )

    Tracks when an idempotency key was first seen and when it expires.

    IdempotencyStore

    In-memory idempotency store. Keys are derived from event identity by the caller, typically through a SHA-256 digest of the event id and type.

    IdempotencyStore::check_and_mark

    fn IdempotencyStore::check_and_mark(self : IdempotencyStore, key : String, event_id : String, now_ms : Int, ttl_ms : Int) -> Bool

    Returns true when key has not been seen inside the TTL window, and records it in that case. Returns false for duplicate keys inside the window.

    IdempotencyStore::clear_expired

    fn IdempotencyStore::clear_expired(self : IdempotencyStore, now_ms : Int) -> Unit

    Removes records whose TTL has passed. Call this from the scheduler before processing a batch so the table does not grow without bound.

    IdempotencyStore::get

    fn IdempotencyStore::get(self : IdempotencyStore, key : String) -> IdempotencyRecord?

    IdempotencyStore::length

    fn IdempotencyStore::length(self : IdempotencyStore) -> Int

    IdempotencyStore::new

    InMemoryDeadLetter

    pub struct InMemoryDeadLetter {
    entries : Array[DeadLetterEntry]
    } derive(
    Debug
    )

    InMemoryDeadLetter::clear

    fn InMemoryDeadLetter::clear(self : InMemoryDeadLetter) -> Unit

    InMemoryDeadLetter::length

    fn InMemoryDeadLetter::length(self : InMemoryDeadLetter) -> Int

    InMemoryDeadLetter::list

    InMemoryDeadLetter::new

    InMemoryDeadLetter::push

    fn InMemoryDeadLetter::push(self : InMemoryDeadLetter, entry : DeadLetterEntry) -> Unit

    InMemoryOutbox

    pub struct InMemoryOutbox {
    entries : Array[PendingDelivery]
    } derive(
    Debug
    )

    In-memory outbox. Use this as the default in tests and single-process applications; a durable implementation can swap in behind the same shape.

    InMemoryOutbox::enqueue

    fn InMemoryOutbox::enqueue(self : InMemoryOutbox, hook_id : String, event : WebhookEvent, now_ms : Int) -> Unit

    InMemoryOutbox::is_empty

    fn InMemoryOutbox::is_empty(self : InMemoryOutbox) -> Bool

    InMemoryOutbox::length

    fn InMemoryOutbox::length(self : InMemoryOutbox) -> Int

    InMemoryOutbox::new

    InMemoryOutbox::take_all

    Returns every pending delivery in FIFO order and empties the outbox.

    MockTransport

    pub struct MockTransport {
    results : Array[DeliveryResult]
    next :
    Ref
    [Int]
    } derive(
    Debug
    )

    A scripted transport used in tests. It returns the configured results in order and then reports retryable 500 errors once exhausted.

    MockTransport::as_transport

    fn MockTransport::as_transport(self : MockTransport) -> ((Hook, WebhookEvent) -> DeliveryResult)

    Convenience wrapper so a MockTransport can be passed directly to a DeliveryEngine through a closure.

    MockTransport::new

    MockTransport::send

    fn MockTransport::send(self : MockTransport, _hook : Hook, _event : WebhookEvent) -> DeliveryResult

    MoonHook

    pub struct MoonHook {
    registry : HookRegistry
    outbox : InMemoryOutbox
    dead_letter : InMemoryDeadLetter
    idempotency : IdempotencyStore
    audit : AuditLog
    engine : DeliveryEngine
    idempotency_ttl_ms : Int
    }

    High-level facade that ties registration, enqueueing, delivery, dead letters, idempotency, and audit logging into one object.

    MoonHook::audit_entries

    fn MoonHook::audit_entries(self : MoonHook) -> Array[AuditEntry]

    MoonHook::dead_letter_length

    fn MoonHook::dead_letter_length(self : MoonHook) -> Int

    MoonHook::dead_letters

    fn MoonHook::dead_letters(self : MoonHook) -> Array[DeadLetterEntry]

    MoonHook::deliver_now

    fn MoonHook::deliver_now(self : MoonHook, hook_id : String, event : WebhookEvent, now_ms : Int) -> DeliveryOutcome

    Delivers immediately, records audit entries, and moves permanent failures into the dead letter queue.

    MoonHook::drain

    fn MoonHook::drain(self : MoonHook, now_ms : Int) -> Int

    Drains the outbox through the delivery engine and returns the number of deliveries attempted. Skipped or failed deliveries land in the dead letter queue.

    MoonHook::enqueue

    fn MoonHook::enqueue(self : MoonHook, hook_id : String, event : WebhookEvent, now_ms : Int) -> Result[Unit, String]

    Enqueues an event into the outbox after checking hook state and the idempotency window. Returns Err for unknown/inactive hooks and duplicates.

    MoonHook::hook_count

    fn MoonHook::hook_count(self : MoonHook) -> Int

    MoonHook::new

    fn MoonHook::new(transport : (Hook, WebhookEvent) -> DeliveryResult) -> MoonHook

    MoonHook::outbox_length

    fn MoonHook::outbox_length(self : MoonHook) -> Int

    MoonHook::register

    fn MoonHook::register(self : MoonHook, hook : Hook) -> Result[Unit, String]

    MoonHook::with_idempotency_ttl_ms

    fn MoonHook::with_idempotency_ttl_ms(self : MoonHook, idempotency_ttl_ms : Int) -> MoonHook

    PendingDelivery

    pub(all) struct PendingDelivery {
    hook_id : String
    event : WebhookEvent
    queued_at_ms : Int
    attempts_used : Int
    } derive(Eq,
    Debug
    )

    A queued delivery waiting to be processed by a scheduler.

    RetryPolicy

    pub(all) enum RetryPolicy {
    None
    Fixed(Int)
    Exponential(Int, Double, Int)
    } derive(Eq,
    Debug
    )

    Controls whether and how a failed delivery is retried.

    • None: no retry, the first failure moves the event to the dead letter queue.
    • Fixed(delay_ms): every retry waits the same delay.
    • Exponential(initial_ms, multiplier, max_ms): delay grows by a multiplier, capped at max_ms.

    RetryPolicy::describe

    fn RetryPolicy::describe(self : RetryPolicy) -> String

    A short human-readable description used by the CLI and audit logs.

    RetryPolicy::next_delay_ms

    fn RetryPolicy::next_delay_ms(self : RetryPolicy, attempt : Int) -> Int

    Returns the delay before retry attempt, where attempt starts at 1 for the first retry after the initial failure.

    RetryPolicy::should_retry

    fn RetryPolicy::should_retry(self : RetryPolicy, attempt : Int, max_attempts : Int) -> Bool

    Returns true when another attempt is allowed.

    RetryPolicy::validate

    fn RetryPolicy::validate(self : RetryPolicy) -> Result[Unit, String]

    Validates the policy parameters used by a Hook.

    SignatureProvider

    pub(all) enum SignatureProvider {
    GitHub
    Stripe
    Slack
    FeishuBot
    Shopify
    } derive(Eq,
    Debug
    )

    Outbound signing schemes of popular WebHook providers.

    MoonHook is the delivery side, so each variant knows how to sign a raw request body. Because the library also ships a minimal receiver, the same variant can verify the matching provider header or body field again — the sender and the receiver share one implementation.

    • GitHub: X-Hub-Signature-256: sha256=<hex>, HMAC-SHA256 over the raw body. Matches the example in GitHub's "Validating webhook deliveries".
    • Stripe: Stripe-Signature: t=<unix_s>,v1=<hex>, HMAC-SHA256 over "<t>.<body>"; several v1 values may be present during secret rotation.
    • Slack: X-Slack-Signature: v0=<hex> together with X-Slack-Request-Timestamp, HMAC-SHA256 over "v0:<t>:<body>".
    • FeishuBot: the signature travels inside the JSON body as timestamp and sign, where sign = base64(HMAC-SHA256(key = "<t>\n<secret>", message = "")), as in the official custom-bot sample.
    • Shopify: X-Shopify-Hmac-Sha256: <base64>, HMAC-SHA256 over the raw body.

    SignatureProvider::describe

    fn SignatureProvider::describe(self : SignatureProvider) -> String

    A short human readable description, used by the CLI and diagnostics.

    SignatureProvider::header_names

    fn SignatureProvider::header_names(self : SignatureProvider) -> Array[String]

    The header names this scheme uses, in the order sign_headers produces them. FeishuBot returns an empty array.

    SignatureProvider::parse

    fn SignatureProvider::parse(name : String) -> SignatureProvider?

    Parses a provider name, case insensitively: github, stripe, slack, feishu (also feishu-bot / lark) and shopify.

    SignatureProvider::sign_headers

    fn SignatureProvider::sign_headers(self : SignatureProvider, secret : String, payload : String, timestamp_s : Int) -> Array[(String, String)]

    The request headers a delivery must carry for this scheme, already formatted the way the provider expects.

    FeishuBot returns no header: its signature lives in the JSON body, which feishu_bot_text_body builds.

    SignatureProvider::sign_value

    fn SignatureProvider::sign_value(self : SignatureProvider, secret : String, payload : String, timestamp_s : Int) -> String

    The signature value for payload, without any scheme prefix: lowercase hex for GitHub, Stripe and Slack, standard base64 for Shopify and Feishu.

    timestamp_s is only used by the schemes that bind a timestamp (Stripe, Slack, Feishu); GitHub and Shopify sign the raw body alone.

    SignatureProvider::verify

    fn SignatureProvider::verify(self : SignatureProvider, secret : String, headers : Array[(String, String)], payload : String, now_s : Int, tolerance_s : Int) -> Bool

    Verifies an incoming request that was signed with this scheme.

    Header lookup is case insensitive. tolerance_s bounds the age of the signature for the timestamped schemes; pass a negative value to skip the window check (GitHub and Shopify carry no timestamp at all). Verification is always a constant-time comparison and never throws: malformed input simply fails.

    SignedRequest

    pub(all) struct SignedRequest {
    http_method : String
    url : String
    headers : Array[(String, String)]
    body : String
    signature : String
    } derive(Eq,
    Debug
    )

    A fully formed webhook HTTP request ready to be sent by an HTTP transport.

    SignedRequest::with_provider_signature

    fn SignedRequest::with_provider_signature(self : SignedRequest, provider : SignatureProvider, secret : String, timestamp_s : Int) -> SignedRequest

    Returns a copy of request with this provider's signature headers appended.

    The body and the MoonHook native signature are left untouched, so a delivery can satisfy the provider scheme and MoonHook's own receiver at the same time. For FeishuBot the signature belongs in the body: build it with feishu_bot_text_body and use that as the event payload.

    WebhookEvent

    pub(all) struct WebhookEvent {
    id : String
    event_type : String
    subject : String
    payload : String
    timestamp_ms : Int
    idempotency_key : String
    } derive(Eq,
    Debug
    )

    A webhook event is the immutable payload delivered to a Hook.

    WebhookEvent::new

    fn WebhookEvent::new(id : String, event_type : String, subject : String, payload : String, timestamp_ms : Int, idempotency_key : String) -> WebhookEvent

    Constructs a WebhookEvent directly. Prefer EventBuilder for validation.

    HEADER_CONTENT_TYPE

    let HEADER_CONTENT_TYPE : String

    HEADER_EVENT_ID

    let HEADER_EVENT_ID : String

    HEADER_EVENT_TYPE

    let HEADER_EVENT_TYPE : String

    HEADER_GITHUB_SIGNATURE

    let HEADER_GITHUB_SIGNATURE : String

    The header GitHub uses for the raw-body HMAC-SHA256 signature.

    HEADER_IDEMPOTENCY_KEY

    let HEADER_IDEMPOTENCY_KEY : String

    HEADER_SHOPIFY_SIGNATURE

    let HEADER_SHOPIFY_SIGNATURE : String

    The header Shopify uses for the raw-body HMAC-SHA256 signature, base64.

    HEADER_SIGNATURE

    let HEADER_SIGNATURE : String

    HTTP header names used by MoonHook signed requests.

    HEADER_SLACK_SIGNATURE

    let HEADER_SLACK_SIGNATURE : String

    The header Slack uses for v0=<hex>.

    HEADER_SLACK_TIMESTAMP

    let HEADER_SLACK_TIMESTAMP : String

    The header Slack uses for the request timestamp, in seconds.

    HEADER_STRIPE_SIGNATURE

    let HEADER_STRIPE_SIGNATURE : String

    The header Stripe uses for t=<unix_s>,v1=<hex>.

    HEADER_SUBJECT

    let HEADER_SUBJECT : String

    HEADER_TIMESTAMP

    let HEADER_TIMESTAMP : String

    build_signed_request

    fn build_signed_request(hook : Hook, event : WebhookEvent) -> SignedRequest

    Builds a signed POST request from a Hook and an event. The signature is an HMAC-SHA256 digest of "<timestamp_ms>.<payload>", sent together with the timestamp in X-MoonHook-Timestamp so receivers can reject replays.

    bytes_to_hex

    fn bytes_to_hex(bytes : Array[Byte]) -> String

    constant_time_eq

    fn constant_time_eq(left : String, right : String) -> Bool

    Compares two signature strings in constant time to avoid leaking the expected digest through early-exit comparisons.

    dead_letter_from_json

    fn dead_letter_from_json(json : Json) -> Result[DeadLetterEntry, String]

    dead_letter_from_json_string

    fn dead_letter_from_json_string(input : String) -> Result[DeadLetterEntry, String]

    dead_letter_to_json

    fn dead_letter_to_json(entry : DeadLetterEntry) -> Json

    Converts a DeadLetterEntry to a Json value.

    dead_letter_to_json_string

    fn dead_letter_to_json_string(entry : DeadLetterEntry) -> String

    event_from_json

    fn event_from_json(json : Json) -> Result[WebhookEvent, String]

    event_from_json_string

    fn event_from_json_string(input : String) -> Result[WebhookEvent, String]

    event_to_json

    fn event_to_json(event : WebhookEvent) -> Json

    Converts a WebhookEvent to a Json value.

    event_to_json_string

    fn event_to_json_string(event : WebhookEvent) -> String

    feishu_bot_text_body

    fn feishu_bot_text_body(secret : String, text : String, timestamp_s : Int) -> String

    Builds the JSON body of a Feishu custom-bot text message, including the timestamp and sign fields the bot expects.

    find_header

    fn find_header(headers : Array[(String, String)], name : String) -> String?

    Finds a header value by name, returning the last match. Header names are matched case-insensitively, following HTTP semantics.

    hmac_sha256

    fn hmac_sha256(key : Bytes, message : Bytes) -> Array[Byte]

    HMAC-SHA256 keyed hash used to sign webhook payloads. Implemented on top of the pure MoonBit SHA-256, following RFC 2104.

    hook_from_json

    fn hook_from_json(json : Json) -> Result[Hook, String]

    hook_from_json_string

    fn hook_from_json_string(input : String) -> Result[Hook, String]

    hook_to_json

    fn hook_to_json(hook : Hook) -> Json

    Converts a Hook to a Json value.

    hook_to_json_string

    fn hook_to_json_string(hook : Hook) -> String

    pending_delivery_from_json

    fn pending_delivery_from_json(json : Json) -> Result[PendingDelivery, String]

    pending_delivery_from_json_string

    fn pending_delivery_from_json_string(input : String) -> Result[PendingDelivery, String]

    pending_delivery_to_json

    fn pending_delivery_to_json(item : PendingDelivery) -> Json

    Converts a PendingDelivery to a Json value.

    pending_delivery_to_json_string

    fn pending_delivery_to_json_string(item : PendingDelivery) -> String

    retry_policy_from_json

    fn retry_policy_from_json(json : Json) -> Result[RetryPolicy, String]

    retry_policy_to_json

    fn retry_policy_to_json(policy : RetryPolicy) -> Json

    Converts a RetryPolicy to a Json value.

    sha256

    fn sha256(data : Bytes) -> Array[Byte]

    Computes the raw 32-byte SHA-256 digest of data.

    sha256_hex

    fn sha256_hex(data : Bytes) -> String

    Computes the lowercase hex SHA-256 digest of data.

    sign_hmac_sha256

    fn sign_hmac_sha256(secret : String, payload : String) -> String

    Signs a payload string with HMAC-SHA256 and returns the lowercase hex signature that can be sent in a X-MoonHook-Signature header.

    sign_webhook

    fn sign_webhook(secret : String, timestamp_ms : Int, payload : String) -> String

    Signs a webhook payload with HMAC-SHA256 over "<timestamp_ms>.<payload>". Including the timestamp lets receivers reject replayed requests.

    signature_providers

    fn signature_providers() -> Array[SignatureProvider]

    Every supported provider, useful for help output and iteration.

    verify_hmac_sha256

    fn verify_hmac_sha256(secret : String, payload : String, signature : String) -> Bool

    verify_signature_header

    fn verify_signature_header(secret : String, body : String, header_value : String) -> Bool

    Verifies an incoming webhook signature header, accepting either a raw lowercase hex digest or a GitHub-style sha256=<hex> value.

    verify_signed_request

    fn verify_signed_request(secret : String, request : SignedRequest) -> Bool

    verify_webhook

    fn verify_webhook(secret : String, timestamp_ms : Int, payload : String, signature : String, now_ms : Int, tolerance_ms : Int) -> Bool

    Verifies a timestamped webhook signature and requires the timestamp to fall inside [now_ms - tolerance_ms, now_ms + tolerance_ms].

    verify_webhook_request

    fn verify_webhook_request(secret : String, headers : Array[(String, String)], body : String) -> Bool

    Verifies an incoming webhook request using the shared secret, raw body, and the X-MoonHook-Signature header.

    verify_webhook_request_with_timestamp

    fn verify_webhook_request_with_timestamp(secret : String, headers : Array[(String, String)], body : String, now_ms : Int, tolerance_ms : Int) -> Bool

    Verifies a MoonHook native signed request using X-MoonHook-Timestamp and the signature header, rejecting messages outside the freshness window.