Guides

Send a WhatsApp message when a Stripe payment fails

Your own server or automation tool receives your Stripe webhooks and calls HelloHop to send the WhatsApp message. Verify the Stripe-Signature on the raw body, remember each event id so a retry never sends twice, find the customer's phone number, and send only to people who agreed to WhatsApp messages.

Facts (as of October 2026, API v1)

  • HelloHop never sees your Stripe keys, signing secret or events. Your code or tool talks to Stripe, and calls POST /v1/sessions/{channel}/messages/text.
  • Stripe signs each delivery with HMAC-SHA256 over the timestamp, a dot and the raw body, and its libraries allow 5 minutes between timestamp and clock.
  • Stripe retries a failed delivery for up to three days in live mode, with exponential backoff.
  • The HelloHop send API has no idempotency key, so the deduplication store is yours.
  • Many Stripe Customers have no phone number. Without one, the recipe skips the customer.

Steps

  1. Create a webhook endpoint in your own Stripe account. Register an HTTPS URL for invoice.payment_failed, invoice.paid, invoice.upcoming and customer.subscription.updated. Copy the whsec_ signing secret of that endpoint.
  2. Verify the signature on the raw body. Reject anything whose Stripe-Signature does not match or is older than 5 minutes.
  3. Record the event id before you send. Insert the event id into a table with a unique key. If the insert finds the id already there, answer 200 and stop.
  4. Find the phone number and the consent. Use a number with a country code from the invoice, from the lookupPhone(customerId) hook that fetches the customer, or from your own records. No number or no consent means no message.
  5. Send once. Call HelloHop with the text. Treat a timeout as possibly delivered and never resend it automatically.

How the pieces fit

Stripe posts an event to a URL you own. Your code checks that Stripe sent it, decides whether it needs a message, and makes one HelloHop API call. Nothing of Stripe's passes through HelloHop, and HelloHop does not host this receiver for you. Use your own Stripe account only. The same code runs in a serverless function, a small server, or any automation tool that can verify an HMAC and call an HTTPS API.

Which event sends which message

Stripe eventMessageNotes
invoice.payment_failedFailed paymentInclude the amount and the hosted invoice link so the customer can fix the card.
invoice.paidReceiptSend only when status is paid and the amount is above zero.
invoice.upcomingRenewal reminderStripe sends it ahead of renewal, and you set how many days ahead in the Dashboard. The event's invoice has no id yet, so do not store or link it.
customer.subscription.updatedCancellation noticeSend only when cancel_at_period_end changes to true. Every other update is ignored.

Stripe does not promise to deliver events in order, so never depend on one event arriving before another. Listen only to the event types you use.

Verify the signature (Node.js)

The Stripe-Signature header looks like t=1492774577,v1=<hex>. Stripe signs the timestamp, a dot and the exact request body with the endpoint secret, using HMAC-SHA256. Use the whsec_ secret of this endpoint. Test and live endpoints have different secrets. Ignore every scheme except v1, accept any matching v1 value because two appear while you roll a secret, compare in constant time, and refuse a tolerance of 0, which turns the recency check off.

Node.js: verify.js
import { createHmac, timingSafeEqual } from 'node:crypto';

// Verifies a Stripe-Signature header: t=<unix seconds>,v1=<hex>[,v1=<hex>][,v0=<hex>]
// rawBody must be the exact bytes Stripe sent (a Buffer), never re-serialized JSON.
// secret is the whsec_ signing secret of THIS endpoint, used as-is as the HMAC key.
export function verifyStripeSignature(
  rawBody,
  header,
  secret,
  toleranceSeconds = 300,
  nowSeconds = Math.floor(Date.now() / 1000),
) {
  if (!Buffer.isBuffer(rawBody) || typeof header !== 'string' || !secret) return false;
  // A tolerance of 0 switches the recency check off in some libraries. Refuse it here.
  if (!(toleranceSeconds > 0)) return false;

  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i < 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't' && /^[0-9]{1,12}$/.test(value)) timestamp = value;
    if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(value); // ignore every other scheme
  }
  if (timestamp === null || signatures.length === 0) return false;

  const signedPayload = Buffer.concat([Buffer.from(timestamp + '.'), rawBody]);
  const expected = Buffer.from(createHmac('sha256', secret).update(signedPayload).digest('hex'));
  // Several v1 values appear while an old secret is still active. Any one match is enough.
  const match = signatures.some((s) => timingSafeEqual(Buffer.from(s), expected));
  if (!match) return false;

  return Math.abs(nowSeconds - Number(timestamp)) <= toleranceSeconds;
}

Verify the signature (Python)

Python: verify.py
import hashlib
import hmac
import time


def verify_stripe_signature(raw_body, header, secret, tolerance_seconds=300, now_seconds=None):
    """Verify a Stripe-Signature header: t=<unix seconds>,v1=<hex>[,v1=<hex>][,v0=<hex>].

    raw_body must be the exact bytes Stripe sent, never re-serialized JSON.
    secret is the whsec_ signing secret of THIS endpoint, used as-is as the HMAC key.
    """
    if not isinstance(raw_body, bytes) or not isinstance(header, str) or not secret:
        return False
    # A tolerance of 0 switches the recency check off in some libraries. Refuse it here.
    if not tolerance_seconds > 0:
        return False
    now = int(time.time()) if now_seconds is None else now_seconds

    timestamp = None
    signatures = []
    for part in header.split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t" and value.isascii() and value.isdigit() and len(value) <= 12:
            timestamp = value
        if key == "v1" and len(value) == 64 and all(c in "0123456789abcdef" for c in value):
            signatures.append(value)  # every other scheme is ignored
    if timestamp is None or not signatures:
        return False

    signed_payload = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
    # Several v1 values appear while an old secret is still active. Any one match is enough.
    if not any(hmac.compare_digest(s, expected) for s in signatures):
        return False
    return abs(now - int(timestamp)) <= tolerance_seconds

Hash the bytes you received. A framework that parses the JSON first and hands you re-serialized text breaks the check. In Flask read request.get_data(). In Express use express.raw.

Deduplicate by Stripe event id, then send once

Stripe retries an event for up to three days when your endpoint does not answer 2xx, and it can occasionally deliver the same event twice. Stripe's advice is to log the event ids you have processed. This matters more here than usual, because a send is not safe to repeat: a retry of a handler that already messaged the customer would message them again.

Idempotency: the key is yours

The HelloHop send API has no idempotency key. It does not read an Idempotency-Key header, so sending one changes nothing. The Stripe event id plus your own store is the protection. Keep the store durable. A set in memory forgets every id when the process restarts.

When the send outcome is unknown

Send resultWhat the handler does
200 server_ackMark the event sent and answer 200.
429, or 503 with ambiguous falseRefused before sending (Recipes and Errors in the API docs list these). Release the claim and answer non-2xx so Stripe delivers the event again later.
409The channel is not linked or is paused. The sample releases the claim and answers non-2xx so Stripe delivers the event again later. See Errors in the API docs.
504 send_unconfirmed, a timeout or a lost responsePossibly delivered. Keep the claim, mark it unknown, answer 200, and look at the chat before deciding anything.
Other 500, 502 or 503A write may have applied. Treat it as unknown.
Other 4xx except 409The request is wrong and a retry will not fix it. Mark it failed, log it, answer 200.

The rule is check, then send once. Never resend an unknown outcome automatically. If you resend after looking, accept that the customer may get the message twice.

Find the phone number, and skip when there is none

An invoice can carry customer_phone. A Customer can carry a phone, and many have none, so a customer often cannot be reached this way unless you collect a number. A common choice is a whatsapp key in the Customer metadata, copied to the invoice or fetched with your own Stripe key. The samples read the event first, then call a lookupPhone(customerId) hook that you write: your own code fetches the Customer and returns its phone or metadata value, and the recipe holds no Stripe key. Use a number with a country code. If the number is missing or has no country code, skip the message and log it. Do not guess a country, and do not message a number you pulled from somewhere else.

Send only to customers who agreed

WhatsApp's business policy applies to every message you send, and people report what they did not ask for. Send only to customers who agreed to WhatsApp messages from you, and keep to messages they expect about their own account: a failed payment, a receipt, a renewal reminder. The sample refuses to send until you connect it to your own record of who agreed, and a customer who has not agreed gets nothing.

The handler (Node.js)

The customer messages in these samples are examples. Write your own wording before you use them.

Node.js: handler.js
import { verifyStripeSignature } from './verify.js'; // re-exported so a server needs one import
export { verifyStripeSignature };

const API = 'https://api.hellohop.app/v1';

// Example messages only: write your own wording. Send only to customers who agreed to WhatsApp messages.
// Field names follow the Invoice object of your Stripe API version: look at one real event first.
// Amount conversion follows https://docs.stripe.com/currencies ("Specify amounts in API requests"):
// zero-decimal currencies are already whole units, three-decimal currencies use 1000 minor units,
// and ISK and UGX are sent as two-decimal values whose decimals are always 00 (500 means 5 ISK).
// Check the page again if you add a currency: Stripe changes these lists.
const ZERO_DECIMAL = new Set(['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'vnd', 'vuv', 'xaf', 'xof', 'xpf']);
const THREE_DECIMAL = new Set(['bhd', 'jod', 'kwd', 'omr', 'tnd']);
const TWO_DECIMAL_SHOWN_WHOLE = new Set(['isk', 'ugx']);

function money(amount, currency) {
  const code = currency.toLowerCase();
  let places = 2;
  let divisor = 100;
  if (ZERO_DECIMAL.has(code)) [places, divisor] = [0, 1];
  else if (THREE_DECIMAL.has(code)) [places, divisor] = [3, 1000];
  else if (TWO_DECIMAL_SHOWN_WHOLE.has(code)) [places, divisor] = [0, 100];
  const text = (amount / divisor).toLocaleString('en', { minimumFractionDigits: places, maximumFractionDigits: places });
  return text + ' ' + currency.toUpperCase();
}

// A negative amount (a credit) is never worded as a charge or a payment: the event is skipped and
// logged. Zero is formatted like any amount (0.00 USD, 0 JPY, 0.000 KWD).
const isAmount = (n) => Number.isInteger(n) && n >= 0;

export const MESSAGES = {
  'invoice.payment_failed': (inv) =>
    isAmount(inv.amount_due)
      ? 'Your payment of ' + money(inv.amount_due, inv.currency) + ' did not go through. Please update your card: ' + (inv.hosted_invoice_url ?? '')
      : null,
  'invoice.paid': (inv) =>
    inv.status === 'paid' && isAmount(inv.amount_paid) && inv.amount_paid > 0
      ? 'Thanks, we received your payment of ' + money(inv.amount_paid, inv.currency) + '. Your receipt: ' + (inv.hosted_invoice_url ?? '')
      : null,
  'invoice.upcoming': (inv) =>
    isAmount(inv.amount_due) ? 'Your plan renews soon. The next charge is ' + money(inv.amount_due, inv.currency) + '.' : null,
  'customer.subscription.updated': (sub, event) =>
    sub.cancel_at_period_end === true && event.data.previous_attributes?.cancel_at_period_end === false
      ? 'Your subscription is set to end at the close of this billing period.'
      : null,
};

// Only a number with a country code is used. Never guess a country for a local number.
export function normalizePhone(raw) {
  if (typeof raw !== 'string') return null;
  const s = raw.trim();
  if (!/^\+[0-9 ()-]{7,24}$/.test(s)) return null;
  const digits = s.replace(/[^0-9]/g, '');
  return digits.length >= 8 && digits.length <= 15 ? digits : null;
}

// Phone, first source: the event object itself (the invoice's customer_phone, customer_details or metadata).
export function phoneFromObject(object) {
  return object.customer_phone ?? object.customer_details?.phone ?? object.metadata?.whatsapp ?? null;
}

// Phone, second source: YOUR hook. It receives the Stripe Customer id (object.customer) and returns a
// phone string or null. Your own code fetches the Customer with your own key, for example
// stripe.customers.retrieve(id), and reads its phone or metadata (https://docs.stripe.com/api/customers/object).
// This recipe holds no Stripe client or key. As shipped it finds nothing.
export async function lookupPhone(customerId) {
  return null;
}

// Fail closed: nothing is sent until you wire this to your own record of who agreed.
export async function hasConsent() {
  return false;
}

// One row per Stripe event id. Illustration only, for ONE process: claim is a plain synchronous function with no await between the
// check and the set (Node runs it to the end before any other delivery), so two deliveries handled at the same time cannot both claim an id. A real
// deployment uses one atomic store operation shared by every process: INSERT ... ON CONFLICT DO
// NOTHING on a unique event_id, or Redis SET key value NX.
export function createMemoryStore() {
  const rows = new Map();
  return {
    claim(id) {
      if (rows.has(id)) return false;
      rows.set(id, 'sending');
      return true;
    },
    async set(id, state) {
      rows.set(id, state);
    },
    async release(id) {
      rows.delete(id);
    },
    rows,
  };
}

export class RetryLater extends Error {}

// True when both channel variables are set. handleStripeEvent asks BEFORE it claims an event.
export function channelConfigured(env = process.env) {
  return Boolean(env.CHANNEL_ID && env.CHANNEL_TOKEN);
}

// Returns { ok: true } | { ok: false, outcome: 'refused' | 'unknown' | 'failed' }.
export async function sendText(phone, text, { channelId = process.env.CHANNEL_ID, token = process.env.CHANNEL_TOKEN, api = API } = {}) {
  let res;
  try {
    res = await fetch(api + '/sessions/' + channelId + '/messages/text', {
      method: 'POST',
      headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
      body: JSON.stringify({ to: phone, text }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { ok: false, outcome: 'unknown' }; // timeout or lost response: it may have been sent
  }
  if (res.ok) return { ok: true };
  const body = await res.json().catch(() => ({}));
  if (res.status === 429 || res.status === 409) return { ok: false, outcome: 'refused' }; // retry later: release the claim (HelloHop API docs, Recipes and Errors)
  if (res.status === 503 && body.ambiguous === false) return { ok: false, outcome: 'refused' };
  if (res.status >= 500) return { ok: false, outcome: 'unknown' }; // 504 send_unconfirmed, 500, 502, other 503
  return { ok: false, outcome: 'failed' }; // other 4xx: retrying will not help
}

// deps: { store, sendText, lookupPhone(customerId), hasConsent }. Returns what happened; throws RetryLater
// when Stripe should deliver the event again.
export async function handleStripeEvent(event, deps) {
  const build = MESSAGES[event.type];
  if (!build) return 'ignored';
  const object = event.data.object;
  const text = build(object, event);
  if (!text) return 'skipped';
  let raw = phoneFromObject(object);
  if (!raw && typeof object.customer === 'string') raw = await deps.lookupPhone(object.customer);
  const phone = normalizePhone(raw);
  if (!phone) return 'no-phone'; // skip and log; never guess
  if (!(await deps.hasConsent(object))) return 'no-consent';

  // Check the channel settings BEFORE the claim: a claimed event is never sent again, so a missing
  // variable found after the claim would lose the message silently. A custom sendText owns its own check.
  const configured = deps.configured ?? (deps.sendText === sendText ? channelConfigured : () => true);
  if (!(await configured())) throw new RetryLater('CHANNEL_ID or CHANNEL_TOKEN is not set'); // nothing claimed: Stripe delivers it again

  // Dedupe by Stripe event.id BEFORE sending. Stripe retries for up to three days.
  if (!(await deps.store.claim(event.id))) return 'duplicate';

  const result = await deps.sendText(phone, text);
  if (result.ok) {
    await deps.store.set(event.id, 'sent');
    return 'sent';
  }
  if (result.outcome === 'refused') {
    await deps.store.release(event.id); // nothing was sent: let Stripe try again later
    throw new RetryLater('refused before sending');
  }
  // 'unknown' stays claimed: a retry must not send again. Check the chat, then decide by hand.
  await deps.store.set(event.id, result.outcome === 'unknown' ? 'unknown' : 'failed');
  return result.outcome;
}

The receiver (Node.js, no framework)

Node.js: server.js
import { createServer } from 'node:http';
import { RetryLater, createMemoryStore, handleStripeEvent, hasConsent, lookupPhone, sendText, verifyStripeSignature } from './handler.js';

// Reads the exact bytes. Resolves with a Buffer, or null when the body is over the limit, and
// rejects when the sender goes away before the end, so the caller can always answer or drop.
function readBody(req, limit = 1_000_000) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let size = 0;
    let settled = false;
    const settle = (fn, value) => {
      if (settled) return;
      settled = true;
      fn(value);
    };
    req.on('data', (chunk) => {
      if (settled) return;
      size += chunk.length;
      if (size > limit) return settle(resolve, null);
      chunks.push(chunk);
    });
    req.on('end', () => settle(resolve, Buffer.concat(chunks)));
    req.on('error', (err) => settle(reject, err)); // keeps an upload error from becoming an uncaught exception
    req.on('aborted', () => settle(reject, new Error('request aborted')));
    req.on('close', () => settle(reject, new Error('closed before the end of the body')));
  });
}

// A plain HTTPS function body works the same way: read the raw bytes, verify, answer.
export function createWebhookServer({ secret, deps }) {
  return createServer(async (req, res) => {
    if (req.method !== 'POST' || req.url !== '/stripe-webhook') {
      res.statusCode = 404;
      return res.end();
    }
    let raw; // the exact bytes, not parsed JSON
    try {
      raw = await readBody(req);
    } catch {
      // The sender left or the upload failed: nobody is listening, so drop the connection.
      res.destroy();
      return;
    }
    if (raw === null) {
      res.statusCode = 413;
      return res.end();
    }
    if (!verifyStripeSignature(raw, req.headers['stripe-signature'], secret)) {
      res.statusCode = 400;
      return res.end();
    }
    try {
      const outcome = await handleStripeEvent(JSON.parse(raw.toString('utf8')), deps);
      console.log('stripe event handled:', outcome);
      res.statusCode = 200;
    } catch (err) {
      res.statusCode = err instanceof RetryLater ? 503 : 500; // non-2xx: Stripe delivers it again
    }
    res.end();
  });
}

if (process.argv[1] && import.meta.url === new URL(process.argv[1], 'file:').href) {
  const secret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_... of this endpoint
  if (!secret) throw new Error('Set STRIPE_WEBHOOK_SECRET before you start');
  const deps = { store: createMemoryStore(), sendText, lookupPhone, hasConsent };
  createWebhookServer({ secret, deps }).listen(4242);
}

The receiver answers 400 for a bad signature, 200 once the event is handled, and non-2xx when Stripe should try again. This sample sends inside the request, with an 8 second timeout. At higher volume, store the event first, answer 2xx, and send from a queue.

The handler (Python)

Python: handler.py
import json
import os
import threading
import urllib.error
import urllib.request

API = "https://api.hellohop.app/v1"


class RetryLater(Exception):
    pass


# Amount conversion follows https://docs.stripe.com/currencies ("Specify amounts in API requests"):
# zero-decimal currencies are already whole units, three-decimal currencies use 1000 minor units,
# and ISK and UGX are sent as two-decimal values whose decimals are always 00 (500 means 5 ISK).
# Check the page again if you add a currency: Stripe changes these lists.
ZERO_DECIMAL = {"bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "vnd", "vuv", "xaf", "xof", "xpf"}
THREE_DECIMAL = {"bhd", "jod", "kwd", "omr", "tnd"}
TWO_DECIMAL_SHOWN_WHOLE = {"isk", "ugx"}


def money(amount, currency):
    code = currency.lower()
    if code in ZERO_DECIMAL:
        places, divisor = 0, 1
    elif code in THREE_DECIMAL:
        places, divisor = 3, 1000
    elif code in TWO_DECIMAL_SHOWN_WHOLE:
        places, divisor = 0, 100
    else:
        places, divisor = 2, 100
    return f"{amount / divisor:,.{places}f} {currency.upper()}"


def is_amount(n):
    # A negative amount (a credit) is never worded as a charge or a payment: the event is skipped and
    # logged. Zero is formatted like any amount (0.00 USD, 0 JPY, 0.000 KWD).
    return isinstance(n, int) and not isinstance(n, bool) and n >= 0


# Example messages only: write your own wording.
def build_message(event):
    obj = event["data"]["object"]
    kind = event["type"]
    if kind == "invoice.payment_failed" and is_amount(obj.get("amount_due")):
        return f"Your payment of {money(obj['amount_due'], obj['currency'])} did not go through. Please update your card: {obj.get('hosted_invoice_url') or ''}"
    if kind == "invoice.paid" and obj.get("status") == "paid" and is_amount(obj.get("amount_paid")) and obj["amount_paid"] > 0:
        return f"Thanks, we received your payment of {money(obj['amount_paid'], obj['currency'])}. Your receipt: {obj.get('hosted_invoice_url') or ''}"
    if kind == "invoice.upcoming" and is_amount(obj.get("amount_due")):
        return f"Your plan renews soon. The next charge is {money(obj['amount_due'], obj['currency'])}."
    if kind == "customer.subscription.updated":
        previous = event["data"].get("previous_attributes") or {}
        if obj.get("cancel_at_period_end") is True and previous.get("cancel_at_period_end") is False:
            return "Your subscription is set to end at the close of this billing period."
    return None


def normalize_phone(raw):
    """Only a number with a country code is used. Never guess a country for a local number."""
    if not isinstance(raw, str):
        return None
    s = raw.strip()
    if not s.startswith("+") or any(c not in "0123456789 ()-" for c in s[1:]):
        return None
    digits = "".join(c for c in s if c.isdigit())
    return digits if 8 <= len(digits) <= 15 else None


def phone_from_object(obj):
    # First source: the event object itself (the invoice's customer_phone, customer_details or metadata).
    return obj.get("customer_phone") or (obj.get("customer_details") or {}).get("phone") or (obj.get("metadata") or {}).get("whatsapp")


def lookup_phone(customer_id):
    # Second source: YOUR hook. It receives the Stripe Customer id (obj["customer"]) and returns a phone
    # string or None. Your own code fetches the Customer with your own key, for example
    # stripe.Customer.retrieve(customer_id), and reads its phone or metadata
    # (https://docs.stripe.com/api/customers/object). This recipe holds no Stripe client or key.
    # As shipped it finds nothing.
    return None


def has_consent(obj):
    return False  # fail closed: wire this to your own record of who agreed to WhatsApp messages


class MemoryStore:
    """Illustration only, for ONE process. The lock makes the check and the set one step, so two
    threads cannot both claim an id. A real deployment uses one atomic store operation shared by every
    process: INSERT ... ON CONFLICT DO NOTHING on a unique event_id, or Redis SET key value NX."""

    def __init__(self):
        self.rows = {}
        self._lock = threading.Lock()

    def claim(self, event_id):
        with self._lock:
            if event_id in self.rows:
                return False
            self.rows[event_id] = "sending"
            return True

    def set(self, event_id, state):
        with self._lock:
            self.rows[event_id] = state

    def release(self, event_id):
        with self._lock:
            self.rows.pop(event_id, None)


def channel_configured():
    """True when both channel variables are set. handle_stripe_event asks BEFORE it claims an event."""
    return bool(os.environ.get("CHANNEL_ID") and os.environ.get("CHANNEL_TOKEN"))


def send_text(phone, text, api=API):
    """Returns (ok, outcome) with outcome in refused, unknown, failed."""
    req = urllib.request.Request(
        f"{api}/sessions/{os.environ['CHANNEL_ID']}/messages/text",
        data=json.dumps({"to": phone, "text": text}).encode(),
        headers={"Authorization": f"Bearer {os.environ['CHANNEL_TOKEN']}", "Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=8):
            return True, None
    except urllib.error.HTTPError as err:
        body = {}
        try:
            body = json.loads(err.read() or b"{}")
        except ValueError:
            pass
        if err.code in (429, 409) or (err.code == 503 and body.get("ambiguous") is False):
            return False, "refused"  # retry later: release the claim (HelloHop API docs, Recipes and Errors)
        return (False, "unknown") if err.code >= 500 else (False, "failed")
    except Exception:
        return False, "unknown"  # timeout or lost response: it may have been sent


def handle_stripe_event(event, store, send=send_text, lookup=lookup_phone, consent=has_consent, configured=None):
    text = build_message(event)
    if text is None:
        return "ignored"
    obj = event["data"]["object"]
    raw = phone_from_object(obj)
    if not raw and isinstance(obj.get("customer"), str):
        raw = lookup(obj["customer"])
    phone = normalize_phone(raw)
    if not phone:
        return "no-phone"  # skip and log; never guess
    if not consent(obj):
        return "no-consent"
    # Check the channel settings BEFORE the claim: a claimed event is never sent again, so a missing
    # variable found after the claim would lose the message silently. A custom send owns its own check.
    if configured is None:
        configured = channel_configured if send is send_text else (lambda: True)
    if not configured():
        raise RetryLater("CHANNEL_ID or CHANNEL_TOKEN is not set")  # nothing claimed: Stripe delivers it again
    # Dedupe by Stripe event.id BEFORE sending. Stripe retries for up to three days.
    if not store.claim(event["id"]):
        return "duplicate"
    ok, outcome = send(phone, text)
    if ok:
        store.set(event["id"], "sent")
        return "sent"
    if outcome == "refused":
        store.release(event["id"])  # nothing was sent: let Stripe try again later
        raise RetryLater("refused before sending")
    # "unknown" stays claimed: a retry must not send again. Check the chat, then decide by hand.
    store.set(event["id"], outcome)
    return outcome

The send call by hand

curl (example message text)
curl -s -X POST "https://api.hellohop.app/v1/sessions/$CHANNEL_ID/messages/text" \
  -H "Authorization: Bearer $CHANNEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"to":"66XXXXXXXXX","text":"Your payment did not go through. Please update your card."}'

Test it with your own Stripe account

Common mistakes

Questions

Does HelloHop receive my Stripe webhooks?
No. Your own code or automation tool receives them in your own Stripe account and calls the HelloHop API. HelloHop never sees your Stripe keys, signing secret or events.
How do I stop Stripe retries from sending a message twice?
Claim the Stripe event id in a durable store with a unique key before you send, and skip an event whose id is already there. Stripe retries for up to three days, so the store must survive restarts.
Can I use an idempotency key with HelloHop?
No. The send API has no idempotency key and does not read an Idempotency-Key header. Deduplicate on the Stripe event id in your own store.
What if the customer has no phone number in Stripe?
Skip the message and log it. Many Customers have none. Collect a number with a country code, for example in metadata, from customers who agreed to WhatsApp messages.
What if a send times out?
Treat it as possibly delivered. Keep the event marked as claimed, answer 200 so Stripe stops retrying, and check the chat before sending anything again.
How do I test it without a real payment?
Use the Stripe CLI on your own account: stripe listen to forward events and stripe trigger to create them. Use a sandbox first.

Free sandbox, forever. Add a real number when you're ready.

Start free

HelloHop is an independent WhatsApp API for developers: send and receive messages, automate groups and communities, and get signed webhooks with a free sandbox. Not affiliated with WhatsApp or Meta. You link your own number.

Send WhatsApp Messages from Stripe Events | HelloHop