Campaigns and notifications
Create a geo or push campaign, add its notifications, and publish or pause it. A campaign decides when and to whom; its notifications are what people see.
A campaign sets the schedule and the audience. Its notifications hold the content (a headline, a body, an optional image or video and button, or a survey) and say exactly when each one fires. You create the campaign first, then add notifications to it.
You need a token with campaigns:write and notifications:write (and the matching read abilities to fetch them back). See Authentication.
Two kinds of campaign#
| Type | Fires | Targets | Schedule |
|---|---|---|---|
geo | When a device enters or leaves one of its locations | location_ids and/or location_group_ids (at least one) | starts_at, and optionally ends_at |
push | At fixed times on one day | Everyone, or the segments in segmentation_ids | One day: starts_at is the date |
A geo campaign can't take segmentation_ids, and a push campaign can't take location_ids or location_group_ids.
Dates and times#
Write campaign dates in the campaign's own timezone (an IANA name such as Europe/London), as YYYY-MM-DD or YYYY-MM-DD HH:MM:
starts_atis required. A date on its own starts at 00:00 that day. It can't be before today.ends_atis optional for a geo campaign. A date on its own ends at the end of that day.- A push campaign runs for the whole of its
starts_atday, and anyends_atis ignored.
Responses give every time as ISO 8601 in UTC, such as 2026-10-05T08:00:00+00:00.
Set up a geo campaign#
- 1Find your locationsCall
GET /locationsand note theidof each location the campaign should target. Locations are created by importing GeoJSON. - 2Create the campaign
POST /campaignswith aname(unique in the workspace),type: geo, atimezone,starts_atand thelocation_ids. The response is201with the new campaign.CURLcurl https://api.sandbox.bubbl.tech/platform/v1/campaigns \ -H "Authorization: Bearer $BUBBL_API_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9d2b6f1e-4c3a-4e8b-a7d5-1f0e2c3b4a59" \ -d '{ "name": "Autumn launch", "type": "geo", "timezone": "Europe/London", "starts_at": "2026-10-05", "ends_at": "2026-10-31", "location_ids": ["0192f0a4-6b1e-7c3a-9d2f-5e8b1a4d2001"], "frequency_cap_count": 1, "frequency_cap_period": "day" }'JAVASCRIPTconst response = await fetch('https://api.sandbox.bubbl.tech/platform/v1/campaigns', { method: 'POST', headers: { Authorization: `Bearer ${process.env.BUBBL_API_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ name: 'Autumn launch', type: 'geo', timezone: 'Europe/London', starts_at: '2026-10-05', ends_at: '2026-10-31', location_ids: ['0192f0a4-6b1e-7c3a-9d2f-5e8b1a4d2001'], frequency_cap_count: 1, frequency_cap_period: 'day', }), }); const { data: campaign } = await response.json();PYTHONimport os import uuid import requests response = requests.post( "https://api.sandbox.bubbl.tech/platform/v1/campaigns", headers={ "Authorization": f"Bearer {os.environ['BUBBL_API_TOKEN']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "name": "Autumn launch", "type": "geo", "timezone": "Europe/London", "starts_at": "2026-10-05", "ends_at": "2026-10-31", "location_ids": ["0192f0a4-6b1e-7c3a-9d2f-5e8b1a4d2001"], "frequency_cap_count": 1, "frequency_cap_period": "day", }, timeout=30, ) response.raise_for_status() campaign = response.json()["data"] - 3Pause it while you set it upA new campaign isn't paused, so it goes live at
starts_atas soon as it has a notification. To build it before it goes live, callPOST /campaigns/{id}/pausestraight away.CURLcurl -X POST https://api.sandbox.bubbl.tech/platform/v1/campaigns/$CAMPAIGN_ID/pause \ -H "Authorization: Bearer $BUBBL_API_TOKEN"JAVASCRIPTawait fetch(`https://api.sandbox.bubbl.tech/platform/v1/campaigns/${campaign.id}/pause`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.BUBBL_API_TOKEN}` }, });PYTHONrequests.post( f"https://api.sandbox.bubbl.tech/platform/v1/campaigns/{campaign['id']}/pause", headers={"Authorization": f"Bearer {os.environ['BUBBL_API_TOKEN']}"}, timeout=30, ).raise_for_status() - 4Add a notification
POST /campaigns/{id}/notificationswith the content and the trigger:on_enterto fire when a device arrives, oron_exitwhen it leaves. The response is201with the notification.CURLcurl https://api.sandbox.bubbl.tech/platform/v1/campaigns/$CAMPAIGN_ID/notifications \ -H "Authorization: Bearer $BUBBL_API_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 5a7c3e2d-8b1f-4d6a-9e0c-2b4d6f8a1c3e" \ -d '{ "name": "Welcome offer", "type": "message", "headline": "10% off today", "body": "Show this at the till for 10% off your order.", "cta_label": "See the menu", "cta_url": "https://example.com/menu", "trigger_type": "on_enter" }'JAVASCRIPTconst added = await fetch(`https://api.sandbox.bubbl.tech/platform/v1/campaigns/${campaign.id}/notifications`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.BUBBL_API_TOKEN}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ name: 'Welcome offer', type: 'message', headline: '10% off today', body: 'Show this at the till for 10% off your order.', cta_label: 'See the menu', cta_url: 'https://example.com/menu', trigger_type: 'on_enter', }), });PYTHONrequests.post( f"https://api.sandbox.bubbl.tech/platform/v1/campaigns/{campaign['id']}/notifications", headers={ "Authorization": f"Bearer {os.environ['BUBBL_API_TOKEN']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "name": "Welcome offer", "type": "message", "headline": "10% off today", "body": "Show this at the till for 10% off your order.", "cta_label": "See the menu", "cta_url": "https://example.com/menu", "trigger_type": "on_enter", }, timeout=30, ).raise_for_status() - 5Publish it
POST /campaigns/{id}/publishsetspausedback tofalse. The campaign'sstatusbecomesactive, orscheduledifstarts_atis still to come.CURLcurl -X POST https://api.sandbox.bubbl.tech/platform/v1/campaigns/$CAMPAIGN_ID/publish \ -H "Authorization: Bearer $BUBBL_API_TOKEN"JAVASCRIPTawait fetch(`https://api.sandbox.bubbl.tech/platform/v1/campaigns/${campaign.id}/publish`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.BUBBL_API_TOKEN}` }, });PYTHONrequests.post( f"https://api.sandbox.bubbl.tech/platform/v1/campaigns/{campaign['id']}/publish", headers={"Authorization": f"Bearer {os.environ['BUBBL_API_TOKEN']}"}, timeout=30, ).raise_for_status()
The campaign comes back like this:
{
"data": {
"id": "0192f0a4-6b1e-7c3a-9d2f-5e8b1a4d3001",
"name": "Autumn launch",
"type": "geo",
"status": "scheduled",
"starts_at": "2026-10-04T23:00:00+00:00",
"ends_at": "2026-10-31T23:59:59+00:00",
"timezone": "Europe/London",
"paused": false,
"frequency_cap_count": 1,
"frequency_cap_period": "day",
"targeting": {
"location_ids": ["0192f0a4-6b1e-7c3a-9d2f-5e8b1a4d2001"],
"location_group_ids": [],
"segmentation_ids": [],
"segment_match": "any"
},
"created_at": "2026-10-01T09:15:02+00:00",
"updated_at": "2026-10-01T09:15:02+00:00"
}
}starts_at is midnight in London on 5 October, which is 23:00 UTC the day before. ends_at is the end of 31 October, after the clocks go back, so it's 23:59:59 UTC.
Campaign fields#
| Field | Required | What it does |
|---|---|---|
name | Yes | Up to 255 characters, unique among the workspace's campaigns. |
type | Yes | geo or push. |
timezone | Yes | The IANA timezone the dates are in, such as Europe/London. |
starts_at | Yes | YYYY-MM-DD or YYYY-MM-DD HH:MM, not before today. Once a campaign has started, its start can't change. |
ends_at | No | Geo only. YYYY-MM-DD or YYYY-MM-DD HH:MM, after starts_at. |
location_ids | Geo | Ids of locations in this workspace. |
location_group_ids | Geo | Ids of location groups in this workspace. |
segmentation_ids | No | Push only. Ids of segments; leave it out to send to everyone. |
segment_match | No | any (the default) or all: whether a device needs to be in any of the segments or all of them. |
frequency_cap_count, frequency_cap_period | No | How many times the campaign can reach the same device each day, week or month. Send both or neither; leave both out to use your workspace's default. |
A campaign's status is worked out, not stored: ended once ends_at has passed, otherwise paused if it's paused, otherwise scheduled until starts_at, otherwise active.
Notification fields#
| Field | Required | What it does |
|---|---|---|
name | Yes | Your own name for it, up to 255 characters. People don't see it. |
type | Yes | message, or survey if your plan includes surveys. |
headline | Yes | The title people see, up to 65 characters. |
body | Yes | The text people see. |
media_asset_id | No | An image, video or audio file from your workspace's media. Video and audio need a plan that includes them. Not for surveys. |
cta_label, cta_url | No | A button and the address it opens. Send both or neither. Bubbl checks the address is reachable when you save it. |
questions | Survey | 1 to 5 questions. See Surveys. |
trigger_type | Yes | Geo: on_enter or on_exit. Push: fixed_time. |
trigger_config.send_at | Push | When to send, as HH:MM on the campaign's day, in its timezone. It can't be in the past. |
starts_at, ends_at | No | Geo only: a window inside the campaign's own, in the same formats as the campaign's dates. Outside it, this notification doesn't fire. |
Set up a push campaign#
A push campaign sends each notification once, at its send_at time on the campaign's day:
{
"name": "Saturday sale",
"type": "push",
"timezone": "Europe/London",
"starts_at": "2026-10-10",
"segmentation_ids": []
}{
"name": "Sale opens",
"type": "message",
"headline": "The sale starts now",
"body": "Everything in store is 20% off until 6pm.",
"trigger_type": "fixed_time",
"trigger_config": { "send_at": "10:00" }
}In the response, trigger_config.send_at is the full date and time, 2026-10-10 10:00, in the campaign's timezone.
Surveys#
A notification with type: survey asks 1 to 5 questions instead of showing media. Each question has a question_text (up to 500 characters), a type and an optional required flag. The two choice types need 2 to 4 choices:
{
"name": "Visit feedback",
"type": "survey",
"headline": "How was your visit?",
"body": "Two quick questions, and you're done.",
"trigger_type": "on_exit",
"questions": [
{ "question_text": "How would you rate your visit?", "type": "RATING", "required": true },
{
"question_text": "What did you come in for?",
"type": "SINGLE_CHOICE",
"choices": [{ "text": "Coffee" }, { "text": "Food" }, { "text": "Both" }]
}
]
}Question types are OPEN_ENDED, SINGLE_CHOICE, MULTIPLE_CHOICE, RATING, BOOLEAN, NUMBER and SLIDER.
Change, pause and delete#
- Update with
PATCH /campaigns/{id}orPATCH /campaigns/{id}/notifications/{notification_id}. Send the whole campaign or notification, not just the fields that changed: the same rules as creating one apply. On a campaign, an optional field you leave out (ends_at, the targeting or the frequency cap) is cleared. - Pause and publish with
POST /campaigns/{id}/pauseandPOST /campaigns/{id}/publish. Both are safe to repeat: pausing a paused campaign, or publishing one that isn't paused, changes nothing. - Delete a campaign with
DELETE /campaigns/{id}. Its scheduled sends are cancelled. - Remove a notification with
DELETE /campaigns/{id}/notifications/{notification_id}.
GET /campaigns/{id}/notifications returns all of a campaign's notifications at once, oldest first.
Limits and errors#
Your plan limits how many campaigns you can have, how many notifications you can create and how many each campaign can have. Going over one gets 403 limit_exceeded, naming the limit (see Errors). In the Sandbox, publishing a paused campaign counts towards the campaign limit again.
Invalid fields get 422 validation_failed, with the same messages as the dashboard, for example:
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"fields": {
"trigger_type": ["A geo campaign's notifications fire on enter or exit."]
}
}
}