Skip to main content

Stripe Webhooks → DB → Slack

Category: Finance & Commerce · 📦 1 install

Get this pack →

This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.

Receives Stripe webhook events, verifies the signature, stores every event in Postgres with idempotency, and posts a Slack notification. One endpoint handles all event types.


What's included

FilePurpose
config.ymlAirPipe config
schema.sqlPostgres events table

How Stripe signature verification works

Stripe signs each webhook with HMAC-SHA256. The signed message is not just the raw body — it is {timestamp}.{raw_body}, where timestamp comes from the Stripe-Signature header.

The ParseSignature action extracts the timestamp and signature from the header using a regex transform. The VerifySignature action then composes the signed payload inline:

body: "a|ParseSignature::timestamp|.a|raw_body|"

Both interpolation sources are resolved before the HMAC is computed, producing the exact string Stripe signed.


Setup

1. Run the schema

psql $DATABASE_URL -f schema.sql

2. Set managed variables

NameValue
DATABASE_URLyour Postgres connection string
STRIPE_WEBHOOK_SECRETsigning secret for your snapshot webhook endpoint (see step 4)
SLACK_WEBHOOK_URLyour Slack incoming webhook URL

3. Deploy the config

Upload config.yml via the AirPipe dashboard.

4. Register the webhook in Stripe

In Stripe DashboardAdd endpoint:

FieldValue
Endpoint URLhttps://your-airpipe-host/stripe/webhook
Payload styleSnapshot (not Thin) — the config stores and reads the full event object
EventsSelect all you want to handle

After saving, copy the signing secret shown for this endpoint into STRIPE_WEBHOOK_SECRET. Stripe gives each endpoint its own secret — make sure you use the one from the snapshot endpoint, not from any other endpoint.


Testing

Trigger events directly — Stripe delivers them from its own infrastructure and signs with your Dashboard endpoint secret:

# Install: https://stripe.com/docs/stripe-cli
stripe login
stripe trigger checkout.session.completed

You should see the event appear in stripe_events and a Slack message within seconds.

Using stripe listen (local dev only)

stripe listen generates its own signing secret, separate from the Dashboard endpoint secret. If you point it at a deployed AirPipe endpoint configured with the Dashboard secret, signature verification will always fail.

stripe listen --forward-to https://your-airpipe-host/stripe/webhook

This only works if STRIPE_WEBHOOK_SECRET is set to the CLI's secret (printed when the listener starts, beginning with whsec_). Keep this for local dev environments only — do not use the CLI secret in staging or production.

Geographic routing note: stripe listen forwards events from your local machine, so requests are routed to whichever AirPipe node is closest to you. If that node is configured with a different secret than the one the CLI is using, you'll get 401s even though your production setup is correct. Stripe's native delivery always originates from Stripe's US infrastructure regardless of where you are.


Idempotency

Stripe delivers webhooks at least once. The ON CONFLICT (stripe_event_id) DO NOTHING clause in the INSERT silently discards duplicates. If Stripe retries a failed delivery, the second attempt is stored and processed normally since it has the same id.


Per-event handling

To run different logic for specific event types, add actions with run_on_assertion:

- name: HandleCheckoutCompleted
run_when_succeeded: [StoreEvent]
run_on_assertion:
tests:
- action: ParseEvent
value: type
is_equal_to: "checkout.session.completed"
database: main
query: |
UPDATE orders SET status = 'paid'
WHERE stripe_session_id = $1
RETURNING id;
params:
- a|ParseEvent::data.object.id|

Common events to handle:

EventTypical action
checkout.session.completedMark order paid, provision access
invoice.paidExtend subscription
customer.subscription.deletedRevoke access
charge.refundedReverse fulfilment

Replay attack prevention (optional)

Stripe includes a timestamp in every signature to allow tolerance checking. Add this assertion to VerifySignature to reject events older than 5 minutes:

- expression: "a|ParseSignature::timestamp| > a|timestamp:s-300|"
error_message: "Webhook timestamp too old"

Notes

  • The stripe-signature header is lowercased by the time it reaches AirPipe (HTTP headers are canonically lowercase).
  • Stripe may send multiple v1= signatures during key rotation. The regex captures the first one, which is sufficient.
  • v0= signatures (legacy) are ignored — v1 (HMAC-SHA256) is the current standard.

Configuration

config.yml

name: StripeWebhooks

docs: true

# Required managed variables:
# STRIPE_WEBHOOK_SECRET — from Stripe Dashboard → Webhooks → signing secret
# SLACK_WEBHOOK_URL — Slack incoming webhook URL
# DATABASE_URL — Postgres connection string

global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"

interfaces:

# POST /stripe/webhook
# Receives all Stripe events. Configure one webhook endpoint in the Stripe
# Dashboard pointing here and select whichever events you need.
stripe/webhook:
output: http
method: POST

actions:

# Step 1: Extract timestamp + v1 signature from the Stripe-Signature header.
# Header format: t=1234567890,v1=abc123...,v0=def456...
- name: ParseSignature
input: a|headers|
hide_data_on_success: true
hide_data_on_error: true
assert:
http_code_on_error: 401
error_message: "Missing Stripe-Signature header"
tests:
- value: stripe-signature
is_not_null: true
post_transforms:
- extract_with_regex:
value: stripe-signature
expr: 't=(\d+).*?v1=([a-f0-9]+)'
keys: [timestamp, v1_signature]

# Step 2: Verify HMAC. Stripe signs "{timestamp}.{raw_body}" with HMAC-SHA256.
# The body field composes the signed payload using two interpolation sources
# in a single string — the engine resolves both before hashing.
- name: VerifySignature
input: a|ParseSignature|
run_when_succeeded:
actions: [ParseSignature]
http_code_on_error: 401
hide_data_on_success: true
assert:
http_code_on_error: 401
error_message: "Invalid Stripe webhook signature"
tests:
- value: v1_signature
is_valid_hmac:
secret: a|ap_var::STRIPE_WEBHOOK_SECRET|
body: "a|ParseSignature::timestamp|.a|raw_body|"
algorithm: sha256

# Step 3: Parse and validate the event body.
- name: ParseEvent
input: a|body|
run_when_succeeded:
actions: [VerifySignature]
http_code_on_error: 401
hide_data_on_success: true
assert:
tests:
- value: id
is_not_null: true
- value: type
is_not_null: true

# Step 4: Store the event. ON CONFLICT handles Stripe's at-least-once delivery.
- name: StoreEvent
run_when_succeeded: [ParseEvent]
database: main
query: |
INSERT INTO stripe_events (stripe_event_id, event_type, payload)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (stripe_event_id) DO NOTHING
RETURNING id;
params:
- a|ParseEvent::id|
- a|ParseEvent::type|
- a|ParseEvent|
hide_data_on_success: true

# Step 5: Notify Slack.
- name: NotifySlack
run_when_succeeded: [StoreEvent]
http:
url: a|ap_var::SLACK_WEBHOOK_URL|
method: POST
headers:
content-type: application/json
body: |
{
"text": "Stripe: `a|ParseEvent::type|`",
"attachments": [
{
"color": "#4CAF50",
"fields": [
{ "title": "Event ID", "value": "a|ParseEvent::id|", "short": true }
]
}
]
}

schema.sql

CREATE TABLE IF NOT EXISTS stripe_events (
id BIGSERIAL PRIMARY KEY,
stripe_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_stripe_events_type ON stripe_events (event_type);
CREATE INDEX IF NOT EXISTS idx_stripe_events_processed_at ON stripe_events (processed_at DESC);