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/githubpnpm add @better-webhook/githubyarn add @better-webhook/githubimport { 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 pushcommits— Array of commit objectsrepository— Repository informationpusher— 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 numberpull_request— Full PR object withtitle,body,state,head,baserepository— Repository informationsender— 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 withnumber,title,body,state,labelsrepository— Repository informationsender— 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:
action—created,deleted,suspend,unsuspend,new_permissions_acceptedinstallation— Installation details withid,account,permissionsrepositories— Array of accessible repos (only forcreatedaction)
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/stripepnpm add @better-webhook/stripeyarn add @better-webhook/stripeimport { 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 iddata.object.amount/currency— Amount detailsdata.object.failure_code/failure_message— Failure diagnosticsdata.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 iddata.object.mode— Session mode (payment,subscription, etc.)data.object.payment_status— Payment state (paid,unpaid, etc.)data.object.amount_total/currency— Checkout totalsdata.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 iddata.object.status— Final status (succeeded)data.object.amount/currency— Payment amountdata.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
v1signatures are used for verification (non-v1schemes 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/ragiepnpm add @better-webhook/ragieyarn add @better-webhook/ragieimport { 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 identifiernonce— Unique idempotency key for this webhook deliverystatus—indexed,keyword_indexed,ready, orfailedname— Document namepartition— Partition keymetadata— 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 isfailed(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 identifiername— Document namepartition— Partition keymetadata— 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 entitydocument_id— Source document IDinstruction_id— Instruction ID used for extractiondocument_name— Source document namedocument_external_id— External ID of source documentdocument_metadata— Metadata from source documentpartition— Partition keysync_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 identifiersync_id— Sync identifierpartition— Partition keycreate_count— Number of documents to be createdupdate_content_count— Number of documents with content updatesupdate_metadata_count— Number of documents with metadata updatesdelete_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 identifiersync_id— Sync identifierpartition— Partition keycreate_count/created_count— Total to create / created so farupdate_content_count/updated_content_count— Content updates total / completedupdate_metadata_count/updated_metadata_count— Metadata updates total / completeddelete_count/deleted_count— Total to delete / deleted so farerrored_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 identifiersync_id— Sync identifierpartition— 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 identifierpartition— Partition keylimit_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 keylimit_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/recallpnpm add @better-webhook/recallyarn add @better-webhook/recallimport { 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.joinparticipant_events.leaveparticipant_events.updateparticipant_events.speech_onparticipant_events.speech_offparticipant_events.webcam_onparticipant_events.webcam_offparticipant_events.screenshare_onparticipant_events.screenshare_offparticipant_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 metadatadata.timestamp- Absolute and relative event timestampsdata.data- Event-specific data (chat content forchat_message, otherwise oftennull)realtime_endpoint/participant_events/recording/bot- Related Recall resources
transcript.*
Events:
transcript.datatranscript.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 timestampsdata.participant- Speaker informationtranscript/recording/bot- Related Recall resources
bot.*
Events:
bot.joining_callbot.in_waiting_roombot.in_call_not_recordingbot.recording_permission_allowedbot.recording_permission_deniedbot.in_call_recordingbot.call_endedbot.donebot.fatalbot.breakout_room_enteredbot.breakout_room_leftbot.breakout_room_openedbot.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 codedata.sub_code- Optional additional reason codedata.updated_at- Status update timestampbot- 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/resendpnpm add @better-webhook/resendyarn add @better-webhook/resendimport { 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.sentemail.scheduledemail.deliveredemail.delivery_delayedemail.complainedemail.bouncedemail.openedemail.clickedemail.receivedemail.failedemail.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 auditingdata.email_id- Stable Resend email identifierdata.created_at- Email object timestampdata.from/data.to/data.subject- Core email metadata (data.subjectdefaults to""when Resend omits it onemail.received)data.tags- Tag payload from Resend asRecord<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.createddomain.updateddomain.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 identifierdata.name- Domain namedata.status- Aggregated verification statusdata.region- Region where the domain is configureddata.records- Verification record details
contact.*
Events:
contact.createdcontact.updatedcontact.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 identifierdata.audience_id- Audience identifierdata.segment_ids- Segment memberships; may be omitted oncontact.deleteddata.email/data.first_name/data.last_name- Contact profile datadata.unsubscribed- Team-level unsubscribe state; may be omitted oncontact.deleted
Signature Verification
Resend uses Svix-compatible signature headers:
svix-idsvix-timestampsvix-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).deliveryIdis not set. - Ragie:
body.nonce(exposed aspayload.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.