ChangelogEdit pageSTAFFOpen dashboard

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#

TypeFiresTargetsSchedule
geoWhen a device enters or leaves one of its locationslocation_ids and/or location_group_ids (at least one)starts_at, and optionally ends_at
pushAt fixed times on one dayEveryone, or the segments in segmentation_idsOne 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_at is required. A date on its own starts at 00:00 that day. It can't be before today.
  • ends_at is 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_at day, and any ends_at is ignored.

Responses give every time as ISO 8601 in UTC, such as 2026-10-05T08:00:00+00:00.

Set up a geo campaign#

  1. 1
    Find your locations
    Call GET /locations and note the id of each location the campaign should target. Locations are created by importing GeoJSON.
  2. 2
    Create the campaign
    POST /campaigns with a name (unique in the workspace), type: geo, a timezone, starts_at and the location_ids. The response is 201 with the new campaign.
    CURL
    curl 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"
      }'
    JAVASCRIPT
    const 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();
    PYTHON
    import 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"]
  3. 3
    Pause it while you set it up
    A new campaign isn't paused, so it goes live at starts_at as soon as it has a notification. To build it before it goes live, call POST /campaigns/{id}/pause straight away.
    CURL
    curl -X POST https://api.sandbox.bubbl.tech/platform/v1/campaigns/$CAMPAIGN_ID/pause \
      -H "Authorization: Bearer $BUBBL_API_TOKEN"
    JAVASCRIPT
    await fetch(`https://api.sandbox.bubbl.tech/platform/v1/campaigns/${campaign.id}/pause`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${process.env.BUBBL_API_TOKEN}` },
    });
    PYTHON
    requests.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()
  4. 4
    Add a notification
    POST /campaigns/{id}/notifications with the content and the trigger: on_enter to fire when a device arrives, or on_exit when it leaves. The response is 201 with the notification.
    CURL
    curl 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"
      }'
    JAVASCRIPT
    const 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',
      }),
    });
    PYTHON
    requests.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()
  5. 5
    Publish it
    POST /campaigns/{id}/publish sets paused back to false. The campaign's status becomes active, or scheduled if starts_at is still to come.
    CURL
    curl -X POST https://api.sandbox.bubbl.tech/platform/v1/campaigns/$CAMPAIGN_ID/publish \
      -H "Authorization: Bearer $BUBBL_API_TOKEN"
    JAVASCRIPT
    await fetch(`https://api.sandbox.bubbl.tech/platform/v1/campaigns/${campaign.id}/publish`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${process.env.BUBBL_API_TOKEN}` },
    });
    PYTHON
    requests.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:

RESPONSE
{
  "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#

FieldRequiredWhat it does
nameYesUp to 255 characters, unique among the workspace's campaigns.
typeYesgeo or push.
timezoneYesThe IANA timezone the dates are in, such as Europe/London.
starts_atYesYYYY-MM-DD or YYYY-MM-DD HH:MM, not before today. Once a campaign has started, its start can't change.
ends_atNoGeo only. YYYY-MM-DD or YYYY-MM-DD HH:MM, after starts_at.
location_idsGeoIds of locations in this workspace.
location_group_idsGeoIds of location groups in this workspace.
segmentation_idsNoPush only. Ids of segments; leave it out to send to everyone.
segment_matchNoany (the default) or all: whether a device needs to be in any of the segments or all of them.
frequency_cap_count, frequency_cap_periodNoHow 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#

FieldRequiredWhat it does
nameYesYour own name for it, up to 255 characters. People don't see it.
typeYesmessage, or survey if your plan includes surveys.
headlineYesThe title people see, up to 65 characters.
bodyYesThe text people see.
media_asset_idNoAn 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_urlNoA button and the address it opens. Send both or neither. Bubbl checks the address is reachable when you save it.
questionsSurvey1 to 5 questions. See Surveys.
trigger_typeYesGeo: on_enter or on_exit. Push: fixed_time.
trigger_config.send_atPushWhen to send, as HH:MM on the campaign's day, in its timezone. It can't be in the past.
starts_at, ends_atNoGeo 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:

POST /campaigns
{
  "name": "Saturday sale",
  "type": "push",
  "timezone": "Europe/London",
  "starts_at": "2026-10-10",
  "segmentation_ids": []
}
POST /campaigns/{id}/notifications
{
  "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:

POST /campaigns/{id}/notifications
{
  "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} or PATCH /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}/pause and POST /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:

RESPONSE
{
  "error": {
    "code": "validation_failed",
    "message": "The given data was invalid.",
    "fields": {
      "trigger_type": ["A geo campaign's notifications fire on enter or exit."]
    }
  }
}