Push notifications from a workflow

Connect Firebase or web push, register devices, send a push from a workflow step, and start a workflow when someone taps an action button.

Last updated

Anythink sends push notifications from a workflow step. You connect a delivery provider once, your app registers each device's token, and a Send a Push Notification step delivers to a user, a list of users, the devices owned by a record, or every device in the project. Every send is logged with its delivery status. Action buttons on a notification can start another workflow when someone taps them.

Part What it does
Provider credentials Firebase Cloud Messaging (FCM) for Android and iOS, and web push (VAPID) for browsers
Device registration Your app sends its push token to the project, tied to a user
The workflow step Builds the notification, picks the audience and queues delivery
Action buttons Buttons on the notification, and a tap that triggers a workflow
History One log row per send, with the delivery status

Anythink delivers through two providers: FCM (fcm) and web push (web_push). iOS devices receive pushes through FCM, so your iOS app registers an FCM token, and you add your APNs key to the Firebase project in the Firebase console. Apple's push service is not a separate provider in Anythink.

Note: Push credentials, the device list, templates, button groups and history are project administrator features. The credentials you upload are encrypted at rest and never returned by the API; it shows only the Firebase project id and the service account email.

Connect a provider#

Set up FCM for Android and iOS apps, web push for browsers, or both. A send goes to every configured provider that has devices for the audience.

FCM (Android and iOS)#

In the Firebase console, open Project settings, then Service accounts, and select Generate new private key. You get a JSON file. Anythink checks that it has type set to service_account, a project_id and a client_email, and rejects anything else.

In the Anythink dashboard

  1. Open Settings and select Push Notifications.
  2. On the Providers tab, find Firebase Cloud Messaging and select Upload service-account JSON.
  3. Choose the file, then select Save.
  4. The badge changes to Configured and the card shows the Firebase project and service account email.

Select Remove to delete the credentials. Devices registered with FCM stop receiving pushes until you upload a new file.

With the CLI

bash
anythink fetch /push/credentials/fcm --method PUT \
  --body "$(jq -n --rawfile sa service-account.json '{service_account_json: $sa}')"

The response holds the project_id and client_email. To check what is configured:

bash
anythink fetch /push/credentials

Each provider (fcm, web_push) shows is_configured and is_active. Calling PUT again replaces the credential.

With an AI assistant (MCP)

Configure FCM push for this project with the service account JSON in ./service-account.json, then show me which providers are configured.

The assistant runs the two fetch calls through the cli tool. The key file stays on your machine and is sent straight to the project.

Web push (browsers)#

Web push uses a VAPID key pair. You give Anythink a contact subject, and it generates the pair, stores the private key encrypted and returns the public key. Your site needs the public key to subscribe a browser.

In the Anythink dashboard

The dashboard doesn't configure web push yet. Use the CLI or an AI assistant.

With the CLI

bash
anythink fetch /push/credentials/web-push --method PUT \
  --body '{"subject":"mailto:admin@example.com"}'

The subject must start with mailto: or https://. The response includes the public_key. Calling this again with a different subject keeps the same key pair, so subscribed browsers keep working.

The public key is also available without authentication, which is what a browser page uses:

http
GET https://api.my.anythink.cloud/org/{project_id}/push/vapid-public-key

With an AI assistant (MCP)

Set up web push for this project with the contact mailto:admin@example.com and tell me the public key.

Keep device tokens in your own entity (optional)#

By default, Anythink stores the tokens that your app registers. If your data model already holds a token column, for example fcm_token on a profiles entity, you can have Anythink read tokens from there instead.

In the Anythink dashboard

On the Providers tab, open the Device source section of the FCM card, choose From an entity field, then pick the entity, the token column and the column that links each row to a user.

With the CLI

bash
anythink fetch /push/credentials/fcm/device-source --method PUT \
  --body '{"kind":"entity_field","entity":"profiles","token_field":"fcm_token","owner_user_field":"user_id"}'

Send {} to go back to Anythink-managed devices. Configure the provider first, or the call returns 404. With an entity source, devices registered through the endpoint below are not used for that provider, and Anythink doesn't clear a stale token from your column.

With an AI assistant (MCP)

Make FCM read device tokens from the fcm_token column on profiles, owned through its user_id column.

Register a device#

Your app registers a device after the user signs in, and again when the push token changes.

http
POST https://api.my.anythink.cloud/org/{project_id}/push/devices
Authorization: Bearer <access token>
Content-Type: application/json

{
  "provider": "fcm",
  "token": "<the device's FCM token>",
  "metadata": { "platform": "ios", "app_version": "1.4.2" }
}
Field Detail
provider Required. fcm or web_push. Anything else returns 400.
token Required. For fcm, the FCM registration token. For web_push, the browser subscription as a JSON string.
metadata Optional object stored with the device.
user_id Optional. The signed-in user is the owner by default. A project administrator can set another user. A user who sets a different user's id gets 403.
owner_entity_name, owner_entity_id Optional, for administrators. Owns the device by a record in another entity, such as a contact, instead of a user.

The response is { "id": 7, "registered_at": "…" }. A token is unique per provider within the project: registering one that already exists refreshes its last-seen time and clears an unregistered mark. If the token already belongs to another user, a non-administrator gets 409.

Anythink marks a token unregistered when the provider reports it dead (for FCM, UNREGISTERED or NOT_FOUND; for web push, 404 or 410), and skips it from then on. Registering it again brings it back.

From a Flutter app#

The Anythink SDK covers sign-in and sessions, not push, so the app calls the endpoint itself. This uses the firebase_messaging and http packages:

http
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
  await http.post(
    Uri.parse('https://api.my.anythink.cloud/org/$projectId/push/devices'),
    headers: {
      'Authorization': 'Bearer $accessToken',
      'Content-Type': 'application/json',
    },
    body: jsonEncode({'provider': 'fcm', 'token': token}),
  );
}

FirebaseMessaging.instance.onTokenRefresh.listen((fresh) {
  // POST the same body again with the new token.
});

The same code registers iOS and Android devices. Both use provider: "fcm".

From a browser#

Fetch the public key, subscribe through a service worker, and register the subscription as the token:

js
const base = `https://api.my.anythink.cloud/org/${projectId}`;
const { public_key } = await (await fetch(`${base}/push/vapid-public-key`)).json();

const registration = await navigator.serviceWorker.register('/sw.js');
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: public_key,
});

await fetch(`${base}/push/devices`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ provider: 'web_push', token: JSON.stringify(subscription) }),
});

Anythink sends the browser a JSON body, and your service worker shows it:

js
self.addEventListener('push', (event) => {
  const { notification, data } = event.data.json();
  event.waitUntil(
    self.registration.showNotification(notification.title, {
      body: notification.body,
      icon: notification.icon,
      image: notification.image,
      actions: notification.actions,
      data,
    }),
  );
});

Check and manage devices#

In the Anythink dashboard

Open Settings, select Push Notifications and open the Registered devices tab. Each row shows the provider, owner, a shortened token, when the device was last seen and whether it is unregistered. Select the bin icon to delete a device.

With the CLI

bash
anythink fetch /push/devices
anythink fetch /push/devices/7 --method DELETE

The list returns each device's provider, user_id, a token_preview, last_seen_at and unregistered_at. Registering with the CLI is also a quick way to test a token before the app exists:

bash
anythink fetch /push/devices --method POST \
  --body '{"provider":"fcm","token":"<token>","user_id":42}'

With an AI assistant (MCP)

List the push devices registered in this project and tell me which are unregistered.

Send a push from a workflow#

Add a Send a Push Notification step to any workflow. The step builds the notification, resolves the audience, queues delivery and then waits for the result.

Step parameters#

Parameter Detail
title, body The notification text. Required unless you set a template_type that supplies them.
template_type The id of a saved push template. Values you set on the step override the template's.
image_url Optional image.
click_action Optional deep link or URL, passed to the app as click_action. Your app decides what to do with it.
category Optional button group id. Needed for tap-to-workflow. See below.
buttons Optional list of { "id", "label", "click_action" }.
data Optional object of extra string values delivered with the notification.
audience Who receives it. See the table below.

Every text parameter accepts template variables such as {{ $anythink.trigger.data.order_id }} and {{ $anythink.steps.lookup.data.name }}. If an expression doesn't resolve, the step fails rather than sending the raw text. See Template syntax.

Choose the audience#

audience.kind Sends to Other fields
user One user's devices user_id. When empty, Anythink uses the user_id in the trigger data.
users Several users user_ids, a list or a variable that resolves to a list of ids.
entity Devices owned by a record in another entity owner_entity_name, and owner_entity_id. When the id is empty, Anythink uses the trigger record's id.
all Every live device in the project None. Use it with care.

Add audience.provider (fcm or web_push) to send through one provider only. Without it, a send goes to every configured provider.

Build the workflow#

This example sends a push to the customer when an order is shipped. It's an API-triggered workflow, so the caller supplies order_id and user_id.

In the Anythink dashboard

  1. Open Workflows, select New Workflow and create a workflow, for example order-shipped.
  2. In the editor, select Add Step and choose Send a Push Notification.
  3. Choose Custom content to write the message, or Use a template to reuse a saved one.
  4. Enter a Title such as Order {{ $anythink.trigger.data.order_id }} shipped, a Body, and optionally an Image and a Deep link / URL when tapped.
  5. Under Send to, choose A single user and enter {{ $anythink.trigger.data.user_id }} as the User id. The other choices are A list of users, A CRM record's owner and Broadcast to every live device.
  6. Save the step and select Enable Workflow.

With the CLI

bash
anythink workflows create order-shipped --trigger Api --api-route order-shipped --enabled
anythink workflows step-add <workflow_id> notify --action SendPushNotification --start --enabled \
  --params '{
    "title": "Order {{ $anythink.trigger.data.order_id }} shipped",
    "body": "Tap to track it",
    "click_action": "myapp://orders/{{ $anythink.trigger.data.order_id }}",
    "data": { "order_id": "{{ $anythink.trigger.data.order_id }}" },
    "audience": { "kind": "user", "user_id": "{{ $anythink.trigger.data.user_id }}" }
  }'

Trigger it with an API key that has anythink_workflows:trigger, as described in Webhooks:

bash
curl -X POST "https://api.my.anythink.cloud/org/{project_id}/workflows/api/order-shipped" \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{"order_id":"1001","user_id":42}'

With an AI assistant (MCP)

Create a workflow called order-shipped with an API trigger on the route order-shipped. Add a Send a Push Notification step that sends "Order {{ $anythink.trigger.data.order_id }} shipped" to the user in trigger data user_id. Enable it.

The assistant runs workflows create and workflows step-add through the cli tool.

What the step returns#

The step waits up to 30 seconds for delivery to finish, then returns:

Output Meaning
send_log_id The history row for this send.
delivery_status Succeeded, Partial, Failed, or pending if delivery was still running after the wait.
delivered, failed, unregistered Counts of devices.
title, body, audience_kind What was sent.

Use them in later steps with {{ $anythink.steps.notify.delivered }}. Partial completes the step and adds a delivery_error. Failed fails the step, which includes an audience with no live devices or no configured provider. Check the job in Workflow monitoring.

To send one notification per record, reference a previous step's list in the audience or text with $anythink.steps.<key>.data[*].field. The step then sends once for each row, ten at a time.

Reuse content with templates#

A template saves a title, body, image, tap target, button group and buttons under a template_type id, so several workflows share one message. Template text accepts the same variables.

In the Anythink dashboard

Open Settings, select Push Notifications and open the Templates tab. Select Start from a pattern or add a template, set a Template type, and save. The template type can't be changed afterwards, because workflows refer to it.

With the CLI

bash
anythink fetch /push/templates --method POST --body '{
  "template_type": "order_shipped",
  "name": "Order shipped",
  "title": "Order {{ $anythink.trigger.data.order_id }} shipped",
  "body": "Tap to track it"
}'

Then set "template_type": "order_shipped" on the step. A template that is switched off (is_active: false) can't be used.

With an AI assistant (MCP)

Create a push template called order_shipped with the title "Order {{ $anythink.trigger.data.order_id }} shipped" and the body "Tap to track it".

Send a test#

Before you wire a workflow, send one by hand.

In the Anythink dashboard

Open the Send notification tab. Optionally choose a template, write or override the content, then choose Specific users and select the recipients, or Broadcast to all live devices. Select the send button.

With the CLI

bash
anythink fetch /push/test --method POST \
  --body '{"title":"Hello","body":"Test message","target_user_id":42}'

The response is { "queued": true, "send_log_id": … }. Use target_user_ids for several users, or owner_entity_name with owner_entity_id for a record. With none of them, the push goes to every live device.

With an AI assistant (MCP)

Send a test push titled Hello to user 42 and show me the delivery result.

Action buttons and tap-to-workflow#

A notification can carry buttons. When someone taps one, the app tells Anythink, and Anythink starts every enabled workflow that listens for that button. A tap on a button group that has no listener does nothing beyond being recorded.

How a button reaches the device#

Each button is { id, label, click_action }. What the device receives depends on the platform:

Platform What Anythink sends What your app does
iOS The category in the APNs payload, so iOS shows the buttons your app registered for that group Registers a notification category with the same group id and action ids at launch. iOS draws the buttons and their labels itself.
Android The buttons as a JSON string in data.buttons, plus data.category Reads data.buttons and builds the notification actions.
Web A standard actions list on the notification, built from each button's id and label Your service worker passes actions to showNotification and reads the clicked action in its notificationclick handler.

Every push also carries data.notification_id, a signed token for that send. The app sends it back when a button is tapped.

Register button groups with Anythink#

A button group is a category id plus the action ids it contains, for example order_update with track and snooze. The app syncs its groups on launch, and Anythink then checks every send against them: if a send uses a group and its buttons include an action id the group doesn't have, the send is rejected with 422 and the registered ids are listed.

http
POST https://api.my.anythink.cloud/org/{project_id}/push/categories/sync
Authorization: Bearer <access token>
Content-Type: application/json

{
  "app_version": "1.4.2",
  "categories": [
    {
      "id": "order_update",
      "actions": [
        { "id": "track", "label": "Track order" },
        { "id": "snooze", "label": "Remind me later" }
      ]
    }
  ]
}

The response lists the groups saved and their action order. Syncing again replaces the actions for a group.

Once your project has any button group, a send can only use groups that are enabled. A send that names a disabled group is rejected: with 422 from the test endpoint, and by failing the workflow step.

In the Anythink dashboard

Open Settings, select Push Notifications and open the iOS button groups tab. Groups your app has synced appear here with their action ids and the labels the app registered. Under Common patterns you can add a typical group, or under Custom button group enter a Group id, Display name and What it shows, and tick Enabled. The id must match the one the app registers. Untick Enabled to stop sends that use a group.

In the Send a Push Notification step, pick the group under iOS button group. The buttons list fills with the group's action ids, and you set only the tap target of each button.

With the CLI

bash
anythink fetch /push/categories
anythink fetch /push/categories --method PUT \
  --body '{"category":"order_update","label":"Order update","is_enabled":true}'

The first call lists the groups and their ios_action_order. The second adds or updates a group's name and enabled state; the action list itself comes from the app's sync.

With an AI assistant (MCP)

Show me the push button groups for this project and which ones are enabled.

Send a notification with buttons#

Set category to the group id and list the buttons in the step:

json
{
  "title": "Your order has shipped",
  "body": "Arrives tomorrow",
  "category": "order_update",
  "buttons": [
    { "id": "track", "label": "Track order", "click_action": "myapp://orders/1001" },
    { "id": "snooze", "label": "Remind me later" }
  ],
  "audience": { "kind": "user", "user_id": "{{ $anythink.trigger.data.user_id }}" }
}

A button's click_action is delivered to the app with the button. Your app decides what to do with it.

Report the tap#

When a button is tapped, the app posts the tap to Anythink. This endpoint needs no access token, because the signed notification_id is the credential.

http
POST https://api.my.anythink.cloud/org/{project_id}/push/action-events
Content-Type: application/json

{
  "notification_id": "<data.notification_id from the push>",
  "category": "order_update",
  "action_id": "snooze",
  "received_at": "2026-10-04T10:00:00Z",
  "responded_at": "2026-10-04T10:00:05Z",
  "data": { "order_id": "1001" }
}
Response Meaning
202 with received: true Recorded, and matching workflows were started.
202 with duplicate: true This notification and action were already reported. No workflow runs again.
400 notification_id, category or action_id is missing.
401 The token is invalid, belongs to another project, doesn't match the category, or has expired. The action id is also rejected if it isn't one of the group's synced actions.

The token is valid for one hour after the send, and it is bound to the send's category. A notification sent without a category can't report taps.

In Flutter, read the token from the message that was tapped:

http
Future<void> reportTap(RemoteMessage message, String actionId) async {
  final notificationId = message.data['notification_id'];
  final category = message.data['category'];
  if (notificationId == null || category == null) return;
  final now = DateTime.now().toUtc().toIso8601String();
  await http.post(
    Uri.parse('https://api.my.anythink.cloud/org/$projectId/push/action-events'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({
      'notification_id': notificationId,
      'category': category,
      'action_id': actionId,
      'received_at': now,
      'responded_at': now,
      'data': message.data,
    }),
  );
}

Start a workflow from a tap#

Create a workflow with an Event trigger on Push Action Taken. The trigger's entity is the button group id, which can be left empty to match any group. Add a filter on action_id to react to one button.

In the Anythink dashboard

  1. Open Workflows and select New Workflow.
  2. Set Trigger Type to Event and Event Type to Push Action Taken.
  3. Under iOS button group, choose the group, or Any group.
  4. Choose an Action, or Any action.
  5. Select Create Workflow, add your steps and select Enable Workflow.

With the CLI

bash
anythink workflows create order-snoozed \
  --trigger Event \
  --event PushActionTaken \
  --entity order_update \
  --filter '{"field":"action_id","op":"eq","value":"snooze"}' \
  --enabled

--entity is the button group id. Then add steps with anythink workflows step-add.

With an AI assistant (MCP)

Create a workflow called order-snoozed that runs when the snooze button in the order_update group is tapped, and enable it.

What the workflow receives#

Trigger data Value
notification_id The send's id.
category, action_id The group and the button tapped.
target_user_id The user the notification was sent to, when the audience was a single user. Empty otherwise.
received_at, responded_at The timestamps the app reported.
Anything in the app's data Added as extra fields, except names already used above.

$anythink.trigger.id is the target user's id, and 0 when the send wasn't to a single user. For example, a follow-up step can notify the same person: set the audience's User id to {{ $anythink.trigger.data.target_user_id }}.

Delivery history#

Every send writes a row with its source, audience, status and per-provider results. A row starts as Pending, moves to Running, and ends as Succeeded, Partial or Failed.

Status Meaning
Succeeded Every attempted device was delivered to, or only dead tokens failed.
Partial Some devices were delivered to and some failed.
Failed Nothing was delivered, or there was no configured provider or live device. The row carries the first error.

In the Anythink dashboard

Open Settings, select Push Notifications and open the History tab. Recent sends shows when each send ran, its status, source (Workflow, Broadcast or Test), title, category, audience and results.

With the CLI

bash
anythink fetch /push/send-logs
anythink fetch /push/send-logs/26

The list is newest first and paged, with page selecting the page. A row from a workflow includes workflow_id, workflow_name and step_key. For the failure reason on a workflow run, read the job with anythink workflows jobs <workflow_id>.

With an AI assistant (MCP)

Show me the last few push sends and why any of them failed.

Limits#

Limit Value
Providers FCM and web push. There is no direct Apple push provider.
Who can manage push Project administrators manage credentials, the device list, templates, button groups, test sends and history.
Devices A token is unique per provider in a project.
Delivery volume Devices are read in pages of 500. FCM sends up to 500 requests at once and web push 100. A failed FCM request that returned 429 or a 5xx is retried up to three times with backoff.
data values Strings only.
Tap token One hour from the send. After that, a tap can't start a workflow.
Step wait The step waits up to 30 seconds for delivery to finish. A slower send reports pending and carries on in the background.
Per-row sends Ten at a time.
Logged body The history row keeps the first 200 characters of the body.
Provider limits Google and browser push services set their own payload size and rate limits.
iOS images The app needs a Notification Service Extension to show an image. Anythink sets the fields it relies on.

Next steps#