message.comDevelopers

Sync Stripe customers to message.com

Keep plan, MRR, and trial status fresh on every contact record. When a Stripe subscription changes, push the new state to message.com. Agents see who is on what plan; AI knows which customers are paying and which are not.

1. Build the webhook handler

The handler verifies the Stripe signature using the official SDK, branches on event type, and finds or creates the matching contact in message.com.

server.ts
// Node / Express handler for Stripe webhooks
import express from "express";
import Stripe from "stripe";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const sig = req.get("stripe-signature")!;
    let evt: Stripe.Event;
    try {
      evt = stripe.webhooks.constructEvent(
        req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!
      );
    } catch {
      return res.status(401).end();
    }

    if (
      evt.type === "customer.subscription.created" ||
      evt.type === "customer.subscription.updated" ||
      evt.type === "customer.subscription.deleted"
    ) {
      const sub = evt.data.object as Stripe.Subscription;
      const customer = await stripe.customers.retrieve(sub.customer as string);
      if (customer.deleted) return res.status(200).end();

      const email = customer.email;
      const customFields = {
        stripeCustomerId: customer.id,
        plan: sub.items.data[0].price.nickname,
        mrr: (sub.items.data[0].price.unit_amount ?? 0) / 100,
        status: sub.status, // active | trialing | past_due | canceled
        currentPeriodEnd: sub.current_period_end,
      };
      const authHeader = { Authorization: `Bearer ${process.env.MESSAGE_WORKSPACE_JWT}` };

      // There is no upsert endpoint: find-or-create by email, then set
      // customFields. See the HubSpot sync tutorial for the same pattern.
      const found = email
        ? await fetch(`https://app.message.com/api/v1/contacts?search=${encodeURIComponent(email)}`, { headers: authHeader })
            .then(r => r.json())
            .then(d => d.contacts.find((c) => c.email === email))
        : null;

      const contactId = found
        ? found.id
        : await fetch("https://app.message.com/api/v1/contacts", {
            method: "POST",
            headers: { ...authHeader, "Content-Type": "application/json" },
            body: JSON.stringify({ email }),
          }).then(r => r.json()).then(d => d.contact.id);

      await fetch(`https://app.message.com/api/v1/contacts/${contactId}`, {
        method: "PATCH",
        headers: { ...authHeader, "Content-Type": "application/json" },
        body: JSON.stringify({ customFields }),
      });
    }

    res.status(200).end();
  }
);

Stripe verifies on raw bytes. The Express raw middleware must run before any JSON parser for this route, otherwise constructEvent rejects every request.

2. Register the endpoint in Stripe

  1. Open the Stripe dashboard.
  2. Go to Developers → Webhooks.
  3. Click Add endpoint.
  4. Endpoint URL: your HTTPS handler.
  5. Events to send: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted. Add customer.created and customer.updated if you want non-subscription customers too.
  6. Copy the Signing secret into STRIPE_WEBHOOK_SECRET.

3. Test with the Stripe CLI

bash
# Set up a tunnel to test locally
stripe listen --forward-to localhost:3000/webhooks/stripe

# Fire a test event
stripe trigger customer.subscription.updated

See the Test webhooks locally tutorial for the ngrok alternative.

4. Verify

  1. Create a real test subscription for a customer whose email matches a known contact in message.com.
  2. Open the contact in app.message.com. Confirm plan, mrr, and status appear in its custom fields.
  3. Cancel the subscription. Confirm status flips to canceled.

Useful events to listen for

  • invoice.payment_failed. flag the contact as paymentIssue: true in custom fields. Agents see it immediately.
  • customer.subscription.trial_will_end. surface a trial-ending banner in the inbox.
  • checkout.session.completed. tie a chat session to a paid conversion (see Track chat-to-paid conversion).

Common pitfalls

  • Retried Stripe deliveries. Stripe retries on 5xx and timeouts. There is no idempotency-key support on message.com's side, but find-then-create-or-update is naturally safe to re-run.
  • Customer email missing. Stripe customers can exist without an email. Since email is the only real contact lookup key, queue those events until an email is added to the Stripe customer.
  • Multiple subscriptions. If a customer has more than one subscription, decide which one to surface as plan. Often the highest MRR.
  • Cross-environment confusion. Stripe test events and live events use different signing secrets. Configure both, route to different attribute sets, or skip test events in production.

Next steps