>_better-webhook
SDK

Providers

Documentation for GitHub, Stripe, Ragie, Recall.ai, and Resend webhook providers with all supported events.

Providers

Providers define the webhook source, including event types, payload schemas, and signature verification.

Focused Guides

GitHub

npm install @better-webhook/github
pnpm add @better-webhook/github
yarn add @better-webhook/github
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";

const webhook = github({ secret: process.env.GITHUB_WEBHOOK_SECRET }).event(
  push,
  async (payload) => {
    console.log(`Push to ${payload.repository.full_name}`);
  },
);

Events

Import events from @better-webhook/github/events:

import { push, pull_request, issues, installation, installation_repositories } from "@better-webhook/github/events";

push

Triggered when commits are pushed to a repository branch or tag.

import { push } from "@better-webhook/github/events";

.event(push, async (payload) => {
  console.log(payload.ref);              // "refs/heads/main"
  console.log(payload.repository.name);  // "my-repo"
  console.log(payload.commits.length);   // Number of commits
  console.log(payload.pusher.name);      // Who pushed

  for (const commit of payload.commits) {
    console.log(commit.message);
    console.log(commit.author.email);
  }
})

Key payload fields:

  • ref — Full git ref (e.g., refs/heads/main)
  • before / after — Commit SHAs before and after push
  • commits — Array of commit objects
  • repository — Repository information
  • pusher — User who pushed

pull_request

Triggered when a pull request is opened, closed, merged, edited, etc.

import { pull_request } from "@better-webhook/github/events";

.event(pull_request, async (payload) => {
  console.log(payload.action);                    // "opened", "closed", etc.
  console.log(payload.number);                    // PR number
  console.log(payload.pull_request.title);        // PR title
  console.log(payload.pull_request.state);        // "open" or "closed"
  console.log(payload.pull_request.merged_at);    // Merge timestamp (if merged)

  if (payload.action === "opened") {
    // New PR opened
  }
})

Key payload fields:

  • action — What happened (opened, closed, synchronize, labeled, etc.)
  • number — Pull request number
  • pull_request — Full PR object with title, body, state, head, base
  • repository — Repository information
  • sender — User who triggered the event

issues

Triggered when an issue is opened, closed, edited, labeled, etc.

import { issues } from "@better-webhook/github/events";

.event(issues, async (payload) => {
  console.log(payload.action);           // "opened", "closed", etc.
  console.log(payload.issue.number);     // Issue number
  console.log(payload.issue.title);      // Issue title
  console.log(payload.issue.state);      // "open" or "closed"
  console.log(payload.issue.labels);     // Array of labels
})

Key payload fields:

  • action — What happened (opened, closed, edited, labeled, etc.)
  • issue — Full issue object with number, title, body, state, labels
  • repository — Repository information
  • sender — User who triggered the event

installation

Triggered when a GitHub App is installed, uninstalled, or has permissions changed.

import { installation } from "@better-webhook/github/events";

.event(installation, async (payload) => {
  console.log(payload.action);                      // "created", "deleted", etc.
  console.log(payload.installation.id);             // Installation ID
  console.log(payload.installation.account.login);  // Account name
  console.log(payload.repositories);                // Repos (for "created" action)
})

Key payload fields:

  • actioncreated, deleted, suspend, unsuspend, new_permissions_accepted
  • installation — Installation details with id, account, permissions
  • repositories — Array of accessible repos (only for created action)

installation_repositories

Triggered when repositories are added to or removed from an installation.

import { installation_repositories } from "@better-webhook/github/events";

.event(installation_repositories, async (payload) => {
  console.log(payload.action);                // "added" or "removed"
  console.log(payload.repositories_added);    // Repos added
  console.log(payload.repositories_removed);  // Repos removed
})

Signature Verification

GitHub uses HMAC-SHA256 signatures sent in the X-Hub-Signature-256 header. The SDK verifies this automatically when a secret is configured.


Stripe

npm install @better-webhook/stripe
pnpm add @better-webhook/stripe
yarn add @better-webhook/stripe
import { stripe } from "@better-webhook/stripe";
import {
  charge_failed,
  checkout_session_completed,
  payment_intent_succeeded,
} from "@better-webhook/stripe/events";

const webhook = stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET })
  .event(charge_failed, async (payload) => {
    console.log(payload.data.object.failure_code);
  })
  .event(checkout_session_completed, async (payload) => {
    console.log(payload.data.object.payment_status);
  })
  .event(payment_intent_succeeded, async (payload) => {
    console.log(payload.data.object.amount);
  });

Events

Import events from @better-webhook/stripe/events:

import {
  charge_failed,
  checkout_session_completed,
  payment_intent_succeeded,
} from "@better-webhook/stripe/events";

charge.failed

Triggered when a charge attempt fails.

Key payload fields:

  • data.object.id — Stripe charge id
  • data.object.amount / currency — Amount details
  • data.object.failure_code / failure_message — Failure diagnostics
  • data.object.payment_intent — Related payment intent id or expanded object

checkout.session.completed

Triggered when a Checkout Session completes successfully.

Key payload fields:

  • data.object.id — Checkout session id
  • data.object.mode — Session mode (payment, subscription, etc.)
  • data.object.payment_status — Payment state (paid, unpaid, etc.)
  • data.object.amount_total / currency — Checkout totals
  • data.object.customer / payment_intent — Ids or expanded objects

payment_intent.succeeded

Triggered when a PaymentIntent reaches succeeded.

Key payload fields:

  • data.object.id — Payment intent id
  • data.object.status — Final status (succeeded)
  • data.object.amount / currency — Payment amount
  • data.object.latest_charge — Charge id or expanded charge object

Signature Verification

Stripe signatures are read from Stripe-Signature. The provider validates:

  • t=<unix timestamp> freshness (300s tolerance by default, configurable)
  • One or more v1=<hmac_sha256> signatures (supports secret rotation)
  • Signed payload format ${t}.${rawBody} (requires raw request body access)
  • Only v1 signatures are used for verification (non-v1 schemes are ignored)

Stripe replay dedupe uses body.id (the event id) as replayKey. Stripe does not provide a standard delivery-id header, so deliveryId is undefined for this provider.


Ragie

npm install @better-webhook/ragie
pnpm add @better-webhook/ragie
yarn add @better-webhook/ragie
import { ragie } from "@better-webhook/ragie";
import { document_status_updated } from "@better-webhook/ragie/events";

const webhook = ragie({ secret: process.env.RAGIE_WEBHOOK_SECRET }).event(
  document_status_updated,
  async (payload) => {
    console.log(`Document ${payload.document_id} is now ${payload.status}`);
  },
);

Ragie webhooks use an envelope structure where the event type is in body.type and the actual payload is in body.payload. Ragie includes a required body.nonce for idempotency, and the SDK attaches it onto the unwrapped payload as payload.nonce for convenience. Deduplication is not enforced unless you enable replay protection in core or implement your own dedupe storage.

Events

Import events from @better-webhook/ragie/events:

import {
  document_status_updated,
  document_deleted,
  entity_extracted,
  connection_sync_started,
  connection_sync_progress,
  connection_sync_finished,
  connection_limit_exceeded,
  partition_limit_exceeded,
} from "@better-webhook/ragie/events";

All Ragie event payloads include nonce as a required idempotency key.

document_status_updated

Triggered when a document enters indexed, keyword_indexed, ready, or failed state.

import { document_status_updated } from "@better-webhook/ragie/events";

.event(document_status_updated, async (payload) => {
  console.log(payload.document_id);   // Document ID
  console.log(payload.status);        // "indexed", "ready", "failed", etc.
  console.log(payload.name);          // Document name
  console.log(payload.external_id);   // Your external ID (if provided)
  console.log(payload.error);         // Error message (if status is "failed")
})

Key payload fields:

  • document_id — Unique document identifier
  • nonce — Unique idempotency key for this webhook delivery
  • statusindexed, keyword_indexed, ready, or failed
  • name — Document name
  • partition — Partition key
  • metadata — User-defined metadata (nullable)
  • external_id — Your external ID (nullable)
  • connection_id — Connection ID if created via connection (nullable)
  • sync_id — Sync ID if part of a sync (nullable)
  • error — Error message if status is failed (nullable)

document_deleted

Triggered when a document is deleted.

import { document_deleted } from "@better-webhook/ragie/events";

.event(document_deleted, async (payload) => {
  console.log(payload.document_id);
  console.log(payload.name);
  console.log(payload.external_id);
})

Key payload fields:

  • document_id — Unique document identifier
  • name — Document name
  • partition — Partition key
  • metadata — User-defined metadata (nullable)
  • external_id — Your external ID (nullable)
  • connection_id — Connection ID (nullable)
  • sync_id — Sync ID (nullable)

entity_extracted

Triggered when entity extraction completes for a document.

import { entity_extracted } from "@better-webhook/ragie/events";

.event(entity_extracted, async (payload) => {
  console.log(payload.entity_id);           // Extracted entity ID
  console.log(payload.document_id);         // Source document ID
  console.log(payload.document_name);       // Source document name
  console.log(payload.instruction_id);      // Extraction instruction ID
  console.log(payload.data);                // Extracted entity data
})

Key payload fields:

  • entity_id — Unique identifier for the extracted entity
  • document_id — Source document ID
  • instruction_id — Instruction ID used for extraction
  • document_name — Source document name
  • document_external_id — External ID of source document
  • document_metadata — Metadata from source document
  • partition — Partition key
  • sync_id — Sync ID (nullable)
  • data — The extracted entity data object

connection_sync_started

Triggered when a connection sync begins.

import { connection_sync_started } from "@better-webhook/ragie/events";

.event(connection_sync_started, async (payload) => {
  console.log(payload.connection_id);
  console.log(payload.sync_id);
  console.log(payload.partition);
  console.log(`Will create ${payload.create_count} documents`);
  console.log(`Will delete ${payload.delete_count} documents`);
})

Key payload fields:

  • connection_id — Connection identifier
  • sync_id — Sync identifier
  • partition — Partition key
  • create_count — Number of documents to be created
  • update_content_count — Number of documents with content updates
  • update_metadata_count — Number of documents with metadata updates
  • delete_count — Number of documents to be deleted

connection_sync_progress

Triggered periodically during a sync to report progress.

import { connection_sync_progress } from "@better-webhook/ragie/events";

.event(connection_sync_progress, async (payload) => {
  console.log(`Created: ${payload.created_count}/${payload.create_count}`);
  console.log(`Deleted: ${payload.deleted_count}/${payload.delete_count}`);
  console.log(`Errors: ${payload.errored_count}`);
})

Key payload fields:

  • connection_id — Connection identifier
  • sync_id — Sync identifier
  • partition — Partition key
  • create_count / created_count — Total to create / created so far
  • update_content_count / updated_content_count — Content updates total / completed
  • update_metadata_count / updated_metadata_count — Metadata updates total / completed
  • delete_count / deleted_count — Total to delete / deleted so far
  • errored_count — Number of documents with errors

connection_sync_finished

Triggered when a connection sync completes.

import { connection_sync_finished } from "@better-webhook/ragie/events";

.event(connection_sync_finished, async (payload) => {
  console.log(`Sync ${payload.sync_id} finished`);
  console.log(`Connection: ${payload.connection_id}`);
})

Key payload fields:

  • connection_id — Connection identifier
  • sync_id — Sync identifier
  • partition — Partition key

connection_limit_exceeded

Triggered when a connection exceeds its page limit.

import { connection_limit_exceeded } from "@better-webhook/ragie/events";

.event(connection_limit_exceeded, async (payload) => {
  console.log(`Connection ${payload.connection_id} hit ${payload.limit_type} limit`);
})

Key payload fields:

  • connection_id — Connection identifier
  • partition — Partition key
  • limit_type — Type of limit exceeded (e.g., "page_limit")

partition_limit_exceeded

Triggered when a partition exceeds its document limit.

import { partition_limit_exceeded } from "@better-webhook/ragie/events";

.event(partition_limit_exceeded, async (payload) => {
  console.log(`Partition ${payload.partition} hit limit`);
})

Key payload fields:

  • partition — Partition key
  • limit_type — Type of limit exceeded (if provided)
  • nonce — Unique idempotency key for this webhook delivery

Signature Verification

Ragie uses HMAC-SHA256 signatures sent in the X-Signature header.


Recall.ai

npm install @better-webhook/recall
pnpm add @better-webhook/recall
yarn add @better-webhook/recall
import { recall } from "@better-webhook/recall";
import {
  participant_events_join,
  transcript_data,
  bot_done,
} from "@better-webhook/recall/events";

const webhook = recall({ secret: process.env.RECALL_WEBHOOK_SECRET })
  .event(participant_events_join, async (payload) => {
    console.log(payload.data.participant.name);
  })
  .event(transcript_data, async (payload) => {
    console.log(payload.data.words.length);
  })
  .event(bot_done, async (payload) => {
    console.log(payload.data.code);
  });

Recall events use an envelope where the SDK reads event type from body.event and validates the unwrapped payload from body.data.

Events

Import events from @better-webhook/recall/events:

import {
  participant_events_join,
  participant_events_leave,
  participant_events_update,
  participant_events_speech_on,
  participant_events_speech_off,
  participant_events_webcam_on,
  participant_events_webcam_off,
  participant_events_screenshare_on,
  participant_events_screenshare_off,
  participant_events_chat_message,
  transcript_data,
  transcript_partial_data,
  bot_joining_call,
  bot_in_waiting_room,
  bot_in_call_not_recording,
  bot_recording_permission_allowed,
  bot_recording_permission_denied,
  bot_in_call_recording,
  bot_call_ended,
  bot_done,
  bot_fatal,
  bot_breakout_room_entered,
  bot_breakout_room_left,
  bot_breakout_room_opened,
  bot_breakout_room_closed,
} from "@better-webhook/recall/events";

participant_events.*

Events:

  • participant_events.join
  • participant_events.leave
  • participant_events.update
  • participant_events.speech_on
  • participant_events.speech_off
  • participant_events.webcam_on
  • participant_events.webcam_off
  • participant_events.screenshare_on
  • participant_events.screenshare_off
  • participant_events.chat_message
import {
  participant_events_join,
  participant_events_chat_message,
} from "@better-webhook/recall/events";

.event(participant_events_join, async (payload) => {
  console.log(payload.data.participant.id);
  console.log(payload.data.timestamp.relative);
})
.event(participant_events_chat_message, async (payload) => {
  console.log(payload.data.data.text);
  console.log(payload.data.data.to);
})

Key payload fields:

  • data.participant - Participant identity and metadata
  • data.timestamp - Absolute and relative event timestamps
  • data.data - Event-specific data (chat content for chat_message, otherwise often null)
  • realtime_endpoint / participant_events / recording / bot - Related Recall resources

transcript.*

Events:

  • transcript.data
  • transcript.partial_data
import {
  transcript_data,
  transcript_partial_data,
} from "@better-webhook/recall/events";

.event(transcript_data, async (payload) => {
  console.log(payload.data.words.map((word) => word.text).join(" "));
})
.event(transcript_partial_data, async (payload) => {
  console.log(payload.data.words.length);
})

Key payload fields:

  • data.words - Transcript word segments with relative timestamps
  • data.participant - Speaker information
  • transcript / recording / bot - Related Recall resources

bot.*

Events:

  • bot.joining_call
  • bot.in_waiting_room
  • bot.in_call_not_recording
  • bot.recording_permission_allowed
  • bot.recording_permission_denied
  • bot.in_call_recording
  • bot.call_ended
  • bot.done
  • bot.fatal
  • bot.breakout_room_entered
  • bot.breakout_room_left
  • bot.breakout_room_opened
  • bot.breakout_room_closed
import {
  bot_joining_call,
  bot_fatal,
} from "@better-webhook/recall/events";

.event(bot_joining_call, async (payload) => {
  console.log(payload.data.code);
})
.event(bot_fatal, async (payload) => {
  console.log(payload.data.sub_code);
})

Key payload fields:

  • data.code - Machine-readable bot status code
  • data.sub_code - Optional additional reason code
  • data.updated_at - Status update timestamp
  • bot - Bot resource metadata

Signature Verification

Recall uses signature headers such as webhook-id, webhook-timestamp, and webhook-signature (with svix-* compatibility for legacy flows). The SDK verifies HMAC-SHA256 signatures automatically when RECALL_WEBHOOK_SECRET (or secret) is provided, and rejects stale timestamps to reduce replay risk.


Resend

npm install @better-webhook/resend
pnpm add @better-webhook/resend
yarn add @better-webhook/resend
import { resend } from "@better-webhook/resend";
import {
  email_bounced,
  email_delivered,
  email_received,
} from "@better-webhook/resend/events";

const webhook = resend({ secret: process.env.RESEND_WEBHOOK_SECRET })
  .event(email_delivered, async (payload) => {
    console.log(payload.data.email_id);
  })
  .event(email_bounced, async (payload) => {
    console.log(payload.data.bounce.type);
  })
  .event(email_received, async (payload) => {
    console.log(payload.data.message_id);
  });

Resend handlers receive the full webhook envelope: { type, created_at, data }. The provider reads the event type from body.type and validates the full payload without unwrapping it.

Events

Import events from @better-webhook/resend/events:

import {
  email_sent,
  email_scheduled,
  email_delivered,
  email_delivery_delayed,
  email_complained,
  email_bounced,
  email_opened,
  email_clicked,
  email_received,
  email_failed,
  email_suppressed,
  domain_created,
  domain_updated,
  domain_deleted,
  contact_created,
  contact_updated,
  contact_deleted,
} from "@better-webhook/resend/events";

email.*

Events:

  • email.sent
  • email.scheduled
  • email.delivered
  • email.delivery_delayed
  • email.complained
  • email.bounced
  • email.opened
  • email.clicked
  • email.received
  • email.failed
  • email.suppressed
import { resend } from "@better-webhook/resend";
import {
  email_delivered,
  email_bounced,
  email_clicked,
  email_received,
} from "@better-webhook/resend/events";

const webhook = resend()
  .event(email_delivered, async (payload) => {
    console.log(payload.data.email_id);
    console.log(payload.data.to);
  })
  .event(email_bounced, async (payload) => {
    console.log(payload.data.bounce.message);
    console.log(payload.data.bounce.subType);
  })
  .event(email_clicked, async (payload) => {
    console.log(payload.data.click.link);
    console.log(payload.data.click.userAgent);
  })
  .event(email_received, async (payload) => {
    console.log(payload.data.message_id);
    console.log(payload.data.attachments?.length ?? 0);
  });

Key payload fields:

  • created_at - Event timestamp for ordering and auditing
  • data.email_id - Stable Resend email identifier
  • data.created_at - Email object timestamp
  • data.from / data.to / data.subject - Core email metadata (data.subject defaults to "" when Resend omits it on email.received)
  • data.tags - Tag payload from Resend as Record<string, string>
  • data.bounce / data.click / data.failed / data.suppressed - Event-specific detail objects when present

email.received is metadata-only. Fetch the full inbound body, headers, and attachments through Resend's receiving APIs when you need the message content.

domain.*

Events:

  • domain.created
  • domain.updated
  • domain.deleted
import { resend } from "@better-webhook/resend";
import { domain_updated } from "@better-webhook/resend/events";

const webhook = resend().event(domain_updated, async (payload) => {
  console.log(payload.data.name);
  console.log(payload.data.status);
  console.log(payload.data.records.length);
});

Key payload fields:

  • data.id - Domain identifier
  • data.name - Domain name
  • data.status - Aggregated verification status
  • data.region - Region where the domain is configured
  • data.records - Verification record details

contact.*

Events:

  • contact.created
  • contact.updated
  • contact.deleted
import { resend } from "@better-webhook/resend";
import { contact_created } from "@better-webhook/resend/events";

const webhook = resend().event(contact_created, async (payload) => {
  console.log(payload.data.email);
  console.log(payload.data.segment_ids);
  console.log(payload.data.unsubscribed);
});

Key payload fields:

  • data.id - Contact identifier
  • data.audience_id - Audience identifier
  • data.segment_ids - Segment memberships; may be omitted on contact.deleted
  • data.email / data.first_name / data.last_name - Contact profile data
  • data.unsubscribed - Team-level unsubscribe state; may be omitted on contact.deleted

Signature Verification

Resend uses Svix-compatible signature headers:

  • svix-id
  • svix-timestamp
  • svix-signature

The SDK verifies the exact raw request body using HMAC-SHA256 over ${id}.${timestamp}.${rawBody} with the base64-decoded portion of the whsec_... secret. By default the signed timestamp must be within 300 seconds of the current time.

Verified but unhandled Resend requests are acknowledged with 200 to match Resend's documented delivery contract, while still requiring signature verification before acknowledgment.


Replay and Idempotency

Core replay protection uses provider-specific replay keys:

  • GitHub: x-github-delivery
  • Stripe: body.id (event id). deliveryId is not set.
  • Ragie: body.nonce (exposed as payload.nonce)
  • Recall.ai: webhook-id/svix-id
  • Resend: svix-id

When replay protection is enabled and a duplicate key is detected, the default response is 409.

import { createInMemoryReplayStore } from "@better-webhook/core";
import { github } from "@better-webhook/github";
import { push } from "@better-webhook/github/events";

const webhook = github()
  .withReplayProtection({
    store: createInMemoryReplayStore(),
  })
  .event(push, async (payload) => {
    await processPush(payload);
  });

Custom Providers

For webhook sources not covered by built-in providers, create a provider in @better-webhook/core and then register events on a webhook builder.

1) Define event schemas and event definitions

import { defineEvent, z } from "@better-webhook/core";

const OrderSchema = z.object({
  orderId: z.string(),
  status: z.enum(["pending", "completed", "cancelled"]),
  amount: z.number(),
});

// Define events for tree-shaking
export const orderCreated = defineEvent({
  name: "order.created",
  schema: OrderSchema,
  provider: "my-ecommerce" as const,
});

export const orderUpdated = defineEvent({
  name: "order.updated",
  schema: OrderSchema,
  provider: "my-ecommerce" as const,
});

2) Create a provider

import { createProvider, createHmacVerifier } from "@better-webhook/core";

export const myProvider = createProvider({
  name: "my-ecommerce",
  getEventType: (headers) => headers["x-event-type"],
  getDeliveryId: (headers) => headers["x-delivery-id"],
  verify: createHmacVerifier({
    algorithm: "sha256",
    signatureHeader: "x-signature",
    signaturePrefix: "sha256=",
  }),
});

3) Create a webhook builder and register handlers

import { createWebhook } from "@better-webhook/core";
import { myProvider, orderCreated, orderUpdated } from "./provider";

const webhook = createWebhook(myProvider)
  .event(orderCreated, async (payload) => {
    await handleOrderCreated(payload.orderId);
  })
  .event(orderUpdated, async (payload) => {
    await handleOrderUpdated(payload.orderId, payload.status);
  });

4) Attach to an adapter

import { toNextJS } from "@better-webhook/nextjs";

export const POST = toNextJS(webhook);

The createHmacVerifier helper supports common signature formats. For custom verification logic, provide your own verify function.

Envelope Payloads

Some webhook providers wrap the actual payload in an envelope structure. For example, a provider might send:

{
  "type": "order.created",
  "payload": { "orderId": "123", "status": "pending", "amount": 99.99 },
  "timestamp": "2024-01-01T00:00:00Z"
}

Use getEventType with the optional body parameter and getPayload to handle this:

const envelopeProvider = createProvider({
  name: "my-envelope-provider",
  // Extract event type from body instead of headers
  getEventType: (headers, body) => {
    if (body && typeof body === "object" && "type" in body) {
      return (body as { type: string }).type;
    }
    return undefined;
  },
  // Extract the actual payload from the envelope
  getPayload: (body) => {
    if (body && typeof body === "object" && "payload" in body) {
      return (body as { payload: unknown }).payload;
    }
    return body;
  },
  verify: createHmacVerifier({
    algorithm: "sha256",
    signatureHeader: "x-signature",
  }),
});

With this configuration, your event handlers receive the unwrapped payload directly, and schema validation applies to the inner payload object.

On this page