> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ibubbl.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Inbound events

> Let a connected app start an iBubbl workflow.

Most of this documentation describes iBubbl talking to you. This page is the
other direction: another app posts an event to iBubbl, and a workflow you built
runs.

It works with any app that can send a webhook — a form builder, a scheduling
tool, your store, Zapier or Make. The case it was first built for: a customer's
card is declined on a subscription renewal, and you want to text them about it
while they can still fix it.

> [!NOTE]
>
> Not to be confused with [Webhooks](https://docs.ibubbl.com/webhooks/overview.md), which is iBubbl
> sending events **to** your server. This page is about events arriving **at**
> iBubbl.

## How it fits together

1. Create a **source** in your dashboard under **Settings → Inbound events**.
   You get a URL with a credential built into it, shown once.
2. Paste that URL into the sending app, and send a test event from it.
3. Open the source's **Events & fields**: the event appears there, and you pick
   the field holding the customer's phone number by clicking it.
4. Build a workflow whose trigger is **Webhook received**, pointed at that
   source. Its event name, Event ID and message fields can all be picked from
   the same event.

When an event arrives, iBubbl checks it (see [Source types](#source-types)),
finds the customer's phone number in the payload, and starts your workflow.

## The endpoint

```
POST https://api.ibubbl.com/v1/events/<token>
```

This is the one endpoint in the API that takes no `Authorization` header — the
credential is the token in the URL. That is not a shortcut: many apps, Recharge
among them, let you configure only a destination URL and give you no way to set
a header.

Treat the URL like a password. If it leaks, use **New URL** on the source (the
old URL stops working at once) or revoke the source.

Bodies may be JSON or `application/x-www-form-urlencoded` form data, up to
256 KB. `multipart/form-data` isn't supported: such a request is accepted but
recorded as **Event wasn't JSON or form data**, and starts nothing.

### What you get back

```json theme={null}
{ "received": true, "runs_started": 1 }
```

`runs_started` is `0` whenever the event was accepted but nothing ran — no
workflow was listening, or the event was a repeat of one already handled.
That is normal. When it is unexpectedly `0`, the source's row in **Settings →
Inbound events** says which of those it was.

Errors are limited to `403` (signature did not match), `404` (no such source, or
it was revoked), `413` (body over 256 KB) and, for secret-URL sources only, `429`
(over 60 events a minute, while a workflow is listening on the source — see
[Source types](#source-types)). Everything else answers `200`, deliberately —
see [Delivery](#delivery-and-retries). (Only `POST` is accepted; a `GET` to the
URL returns `405`.)

## Source types

| Type | Who it's for | How a request is checked |
| - | - | - |
| **Any app (secret URL)** | Any app that can send a webhook | The URL is the key; no signature. At most 60 events a minute per source (see below). |
| **Recharge** | Recharge | Recharge's `X-Recharge-Hmac-Sha256` signature, using your API Client Secret. |
| **iBubbl-signed** | Your own systems | An `X-iBubbl-Signature` header you compute with a secret iBubbl gives you. |

### Any app (secret URL)

Most apps can't sign a request in a way iBubbl could check, so for these the
long, random URL is the credential — the same approach as Zapier's and Make's
catch hooks. Create the source, paste the URL into the app, and send a test
event.

A secret-URL source accepts at most 60 events a minute. Over that, the answer
depends on whether any active workflow is listening on the source: if one is,
iBubbl answers `429` so the app retries later; if none is, the event is
accepted (`200`) and dropped, since it couldn't have started anything. Dropped
events don't appear in the source's recent events. Recharge sources are never
rate limited.

For example:

```bash cURL theme={null}
# No API key: the source's token in the URL is the credential.
curl -X POST "https://api.ibubbl.com/v1/events/$IBUBBL_EVENT_SOURCE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type": "lead.created", "lead": {"id": "evt_1", "phone": "3103843698", "first_name": "Ana"}}'
```

```typescript TypeScript theme={null}
// No API key: the source's token in the URL is the credential.
const res = await fetch(`https://api.ibubbl.com/v1/events/${process.env.IBUBBL_EVENT_SOURCE_TOKEN}`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    type: "lead.created",
    lead: { id: "evt_1", phone: "3103843698", first_name: "Ana" },
  }),
  signal: AbortSignal.timeout(10_000),
});

const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`);
console.log(data.runs_started);
```

```python Python theme={null}
import os

import requests

# No API key: the source's token in the URL is the credential.
TOKEN = os.environ["IBUBBL_EVENT_SOURCE_TOKEN"]

res = requests.post(
    f"https://api.ibubbl.com/v1/events/{TOKEN}",
    json={
        "type": "lead.created",
        "lead": {"id": "evt_1", "phone": "3103843698", "first_name": "Ana"},
    },
    timeout=10,
)
data = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {data['detail']['error']}")
print(data["runs_started"])
```

**Event name.** iBubbl reads it from an `X-Event-Topic` or `X-iBubbl-Topic`
header, or a top-level `topic`, `type`, `event` or `event_type` field. If the
app puts it somewhere else, choose **The event name is somewhere inside the
payload** on the trigger and pick the field.

**Phone number.** By default iBubbl looks for `phone`, `phone_number` or
`contact.phone`. If the app puts it elsewhere — `lead.phone` above — pick it in
the source's **Events & fields**. First and last name work the same way.

### iBubbl-signed

For senders you control. iBubbl creates a signing secret (`whsec_…`, shown
once). Sign each request with:

```
X-iBubbl-Signature: t=<unix seconds>,v1=<hex>
```

where `<hex>` is the HMAC-SHA256, keyed with the secret, of `<t>.` followed by
the raw request body. Requests more than 5 minutes off from `t` are rejected.
This is the same scheme iBubbl uses to sign the [webhooks](https://docs.ibubbl.com/webhooks/overview.md)
it sends you.

## Connecting Recharge

In Recharge, create a webhook with your iBubbl URL as the address and
`charge/failed` as the topic. You can do that from their API:

```bash theme={null}
curl -X POST https://api.rechargeapps.com/webhooks \
  -H "X-Recharge-Access-Token: <your recharge token>" \
  -H "Content-Type: application/json" \
  -d '{
    "address": "https://api.ibubbl.com/v1/events/ibev_...",
    "topic": "charge/failed"
  }'
```

> [!WARNING]
>
> When you create the source in iBubbl, paste your **API Client Secret** — not
> your API token. They are different values. Find it in Recharge under
> **Tools and apps → API tokens →** your token **→ Edit admin API token**.
>
> Pasting the token instead is the most common setup mistake, and it shows up
> as every event failing with "Signature did not match".

Recharge signs each request with an `X-Recharge-Hmac-Sha256` header. Despite the
name, the value is **not** an HMAC — it is a plain SHA-256 of your client secret
followed by the raw request body. iBubbl handles that for you; it is worth
knowing only if you are comparing against your own verifier.

### Writing the message

The whole event is available to your message template under `trigger.event`,
rooted at the app's own payload. **Insert field** on the Send message step lists
the fields of the latest event and writes the right syntax for you. Recharge
wraps a charge, so:

```
Hi {{contact.first_name}} — the card on your subscription was declined.
You can update it here: https://yourstore.rechargeapps.com/portal/{{trigger.event.charge.customer_hash}}/
```

`customer_hash` comes in the payload, so the link costs no extra API call. (It is
`charge.customer_hash` in Recharge's 2021-01 API format. In the 2021-11 format the
charge carries it as `charge.customer.hash` instead — use **Insert field** on a
real event to see which one your store sends.) Link
to the portal root rather than a deeper page: the exact path differs depending
on which Recharge portal your store runs, and Recharge is migrating merchants
between them.

Once the customer updates their card, Recharge reschedules the retry itself — so
"update your card and we'll take care of the rest" is literally true.

## Repeat events

Apps resend events. Recharge in particular sends `charge/failed` **every time it
retries a charge**, and how many times that is depends on the retry settings in
your own Recharge account.

Without protection, a single declined card would text the customer on every one
of those retries.

The **Event ID path** on your trigger is what prevents it, and every
**Webhook received** trigger needs one. It points at the field identifying the
underlying object — for Recharge, `charge.id`. An event resolving to an ID that
already started a run starts nothing new; the source records it as **Already
handled**.

> [!WARNING]
>
> Paths start inside the sending app's payload. Recharge wraps everything in a
> `charge` object, so the path is `charge.id` — not `id`.
>
> A path is field names separated by dots; a number selects an array element
> (`line_items.0.sku`). Don't write a `$.` prefix: `$.charge.id` matches
> nothing.
>
> A path that matches nothing is treated as a configuration error: no workflow
> runs, and the source reports **Event ID path not found**. Sending nothing is
> the safer failure here than sending one message per retry.

Because the ID identifies the object rather than the delivery, this is permanent
for that charge. To send a second, different message later in the dunning cycle,
build a separate workflow on the `charge/max_retries_reached` topic.

## Finding the customer

iBubbl looks for a phone number in the payload — for Recharge, in the billing
address, or the shipping address when the billing phone is blank; for other
sources, where you pointed it (see [Any app](#any-app-secret-url)) — and converts
it to international format. Numbers arrive as the shopper typed them, so
`(310) 384-3698` and `3103843698` both work.

A number without a leading `+` is read in the country named by a two-letter
`country_code` or `country` field in the payload when there is one, otherwise in
the source's default country (the US unless it was set otherwise). A billing
phone that can't be read is not replaced by the shipping one.

A number that cannot be read as a real phone number is **rejected, not guessed
at**, and the event starts nothing. That matters more than it looks: the message
you are sending carries a link to that customer's account, so a number resolved
onto the wrong person would hand a stranger access to someone else's
subscription.

Events for customers with no phone number on file therefore do nothing, and the
source reports **No phone number in event**. If your store collects phone
numbers from only some customers, this feature reaches only those customers.

A contact is created (or an existing one's missing name filled in) **only when a
workflow starts** for the event. Events nothing is listening for add no one to
your contacts.

## Delivery and retries

iBubbl answers `200` to anything it has verified, even when the event goes
nowhere. That is on purpose. Recharge treats `408`, `429` and `5xx` as failures,
retries 19 times over 48 hours, and then **deletes the webhook registration**
and emails you. An endpoint that returned `500` on an unrecognised payload would
eventually unsubscribe you without either side noticing.

It also means the reason an event did nothing cannot live in the HTTP response.
It lives on the source instead. **Settings → Inbound events** shows each
source's recent events — the newest 20, kept for 30 days — with what happened to
each:

| What it says | What happened |
| - | - |
| Started a workflow | An event arrived and a run began. |
| Already handled | A repeat of an event already processed. Expected on charge retries. |
| No workflow listening | Verified, but no active workflow matched this source and topic. |
| No phone number in event | No usable phone number in the payload. |
| Event ID path not found | A workflow matched, but its Event ID path matched nothing in the payload. |
| Signature did not match | Wrong signing secret — for Recharge, usually the API token pasted instead of the Client Secret. |
| Event wasn't JSON or form data | The body couldn't be read. |
| Something went wrong | An error on iBubbl's side; the event was not processed. |

Events dropped over a secret-URL source's rate limit (with no workflow
listening) are not recorded here.

## New URL and revoking

**New URL** gives a source a new URL; the old one starts returning `404` at
once, and the source keeps its settings and history. Update the sending app with
the new URL. Do this if a URL leaks.

Revoking a source is also immediate: its URL starts returning `404` and the
source stops listening for good.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.