Journeys, recipes and events

Your server reports what people do, and journeys decide what to send. Take a cart that someone filled at 10 pm and then left: your server sends cart.updated, and an hour later the reminder goes out, unless order.placed arrives first, in which case nothing is sent. Recipes are journeys like that, already written for shops and social apps.

1. Send events

POST https://api.bolrach.com/push/v1/events
Authorization: Bearer bt_live_...
{ "app_id": "YOUR_APP_ID", "name": "cart.updated", "user_id": "cust_5521",
  "idempotency_key": "cart-5521-v14",
  "properties": { "cart_id": "c_5521", "amount": 84.50, "currency": "USD", "cart_url": "https://shop.example.com/cart",
    "items": [ { "product_id": "sku-2", "name": "Linen shirt", "price": 49.50, "quantity": 1 },
               { "product_id": "sku-3", "name": "Canvas sneakers", "price": 35.00, "quantity": 1 } ] } }

Put your own id for the person in user_id, matching what the app passed to login. Leave it out for catalog events, which concern a product rather than a person. Up to 100 events fit in one POST /events:batch. Money is amount in ordinary units with an ISO currency, or amount_minor in cents.

EventWhenProperties
product.viewedA shopper opened a product pageproduct_id, name, price, currency, url, image_url
cart.updatedThe cart changed; send the whole cart each timecart_id, items, amount, currency, cart_url
cart.clearedThe cart was emptiedcart_id
order.placedAn order went through, with its revenueorder_id, items, amount, currency, order_url
order.paidPayment confirmedorder_id, amount, currency
order.shippedLeft the warehouseorder_id, carrier, tracking_url
order.out_for_deliveryWith the courier todayorder_id
order.deliveredDeliveredorder_id, items, review_url
order.refundedRefund issuedorder_id, amount, currency
review.submittedThe customer reviewed the orderorder_id, rating
wishlist.addedSaved to the wishlist; also watches the priceproduct_id, name, price, currency, url, image_url
wishlist.removedTaken off the wishlistproduct_id
price_alert.createdAsked to hear about a lower priceproduct_id, target_price, currency
stock_alert.createdAsked to hear when it is backproduct_id, name
catalog.price_changedNo user_id. Reaches everyone watching the productproduct_id, name, old_price, new_price, currency, url, image_url
catalog.restockedNo user_id. Reaches everyone who asked for a restock alertproduct_id, name, url, image_url
catalog.out_of_stockNo user_id. Arms the restock alerts againproduct_id
session.startedThe person opened the app or sitenone
user.followedSomeone followed the person in user_idactor_id, actor_name, profile_url
post.likedSomeone liked their postactor_id, actor_name, post_id, post_title, post_url
post.commentedSomeone commented on their postactor_id, actor_name, post_id, comment_id, text, post_url
comment.repliedSomeone replied to their commentactor_id, actor_name, post_id, comment_id, text, url
user.mentionedSomeone mentioned themactor_id, actor_name, context_id, text, url
post.sharedSomeone shared their postactor_id, actor_name, post_id, post_title, post_url
message.receivedA direct message arrived for themactor_id, actor_name, conversation_id, preview, url

GET /event-catalog returns the same list. A template can use any property as {{trigger.name}}, plus a few we work out: {{trigger.items_count}}, {{trigger.first_item_name}}, {{trigger.amount_text}} (USD 84.50), {{trigger.new_price_text}} and {{trigger.price_drop_percent}}. The person's own attributes are under {{user.*}}.

2. Turn on a recipe

POST https://api.bolrach.com/push/v1/apps/YOUR_APP_ID/recipes/abandoned_cart:enable
{ "params": { "first_delay": "3600", "title_1": "You left something in your cart" } }

-> 200 { "recipe": "abandoned_cart", "workflow_id": "...", "version": 1, "status": "published" }

Leave out params to take the defaults, and GET /recipes lists every parameter. A recipe becomes an ordinary journey that you can open in the builder and change. :disable pauses it: nobody new enters, and people already in it finish.

RecipeForStarts onWhat it does
abandoned_cartShopcart.updatedAn hour after the last cart change, then a day later. order.placed ends it and is credited to the reminder.
browse_abandonmentShopproduct.viewedTwo hours after a product view, at most once a day.
order_updatesShoporder.placed to order.deliveredOne push per stage, each replacing the last on the phone.
refundShoporder.refundedThe amount and the order.
price_dropShopcatalog.price_changedEveryone watching the product, once per new low price.
back_in_stockShopcatalog.restockedEveryone who asked, once until it sells out again.
wishlist_reminderShopwishlist.addedThree days later, unless they ordered or removed it.
review_requestShoporder.deliveredThree days later, unless they already reviewed that order.
win_backShopsession.startedAfter 30 quiet days, then 14 more. Marketing consent applies.
new_followerSocialuser.followedJordan and 4 others started following you
likesSocialpost.likedSam and 12 others liked your post, one notification per post
commentsSocialpost.commentedGrouped per post, latest comment as the text
repliesSocialcomment.repliedGrouped per comment
mentionsSocialuser.mentionedSent straight away, threaded per conversation
sharesSocialpost.sharedGrouped per post
direct_messagesSocialmessage.receivedOne notification per conversation that updates, held back while the person is in the app
activity_digestSociallikes, comments, follows, mentions, sharesOne evening summary instead of instant pushes
re_engagementSocialsession.startedAfter a quiet week. Marketing consent applies.

3. Price drops and restocks

A shopper starts watching a product when your app sends wishlist.added, price_alert.created or stock_alert.created, or when you call PUT /apps/{id}/users/{user}/watchers/{product}. When the price falls, send one event with no user. Every watcher hears about it once, and sending the same price again sends nothing:

POST https://api.bolrach.com/push/v1/events
{ "app_id": "YOUR_APP_ID", "name": "catalog.price_changed", "idempotency_key": "tote-price-2026-10-24",
  "properties": { "product_id": "tote", "name": "Leather tote", "old_price": 180, "new_price": 144, "currency": "USD",
                  "url": "https://shop.example.com/p/tote" } }
-> each watcher: "Price drop: Leather tote. Now USD 144.00, was USD 180.00 (20% off)."

4. Grouped social notifications

The first like on a post goes out at once. Likes in the next minute are held, and when the minute is up one push replaces the first on the device: Sam and 12 others liked your post. By the end Maya has seen two pushes for thirteen likes. In the in-app inbox the post takes one row, and the unread badge goes up by one for the post, however many likes it collected:

GET https://api.bolrach.com/push/v1/apps/YOUR_APP_ID/users/user_8123/badge
-> { "unread_count": 2, "groups": [ { "group_key": "likes:p-91", "actor_count": 13, "sends": 2,
                                     "title": "Sam and 12 others liked your post" }, ... ] }

If Maya wants quiet on one noisy post, the app calls PUT /apps/{id}/users/{user}/mutes/{key} with the key likes:p-91. A conversation's key looks like dm:c-17 and a person's like actor:u-44, and an until timestamp makes the mute expire on its own. To stop a whole kind of notification for her, use PUT /apps/{id}/users/{user}/preferences/{category} with a category such as social_likes. The other four are social_comments, social_follows, social_mentions and social_messages.

5. Build your own journey

Start a blank journey in the console under Push, Journeys, or post the same specification. This one waits for the order and reminds once if it never comes:

POST https://api.bolrach.com/push/v1/workflows/{id}/versions
{ "trigger": { "type": "event", "name": "checkout.started",
               "where": { "rules": [ { "path": "amount", "op": "gte", "value": 100 } ] } },
  "settings": { "exit_on": [ { "event": "order.placed" } ],
                "goal": { "event": "order.placed" },
                "reentry": "if_not_active",
                "frequency_cap": { "max_messages": 2, "per_seconds": 86400 },
                "send_window": { "start": "09:00", "end": "21:00", "timezone": "user", "fallback_timezone": "America/New_York" } },
  "entry": "wait_order",
  "steps": [
    { "key": "wait_order", "type": "wait_for_event",
      "config": { "event": "order.placed", "timeout_seconds": 1800, "match": { "checkout_id": "{{trigger.checkout_id}}" } },
      "cases": { "matched": "end", "timeout": "remind" } },
    { "key": "remind", "type": "send_push", "next": "end",
      "config": { "title": "Your checkout is waiting", "body": "{{trigger.amount_text}} is saved.", "url": "{{trigger.url}}" } },
    { "key": "end", "type": "end" } ] }

Nothing runs until POST /workflows/{id}/versions/{version}:publish. Once it is live, an order.placed from the shopper pulls them out of the journey on the spot and cancels the reminder still waiting for them. The reentry setting decides what happens when they start a second checkout: always, if_not_active, once or cooldown. With the send window above, a reminder due at 11 pm in Denver waits until 9 the next morning, Denver time. Run POST /workflows/{id}/canvas:simulate against a test event first, since a simulation sends nothing.

6. Revenue

Send order.placed with amount and currency, and we look back seven days for the last push that reached that person. A push they opened beats one that only arrived. Say a shopper ignores Monday's price drop alert, opens Wednesday's restock push and buys on Thursday: the $144 goes to the restock push. A journey with a goal has its goal events credited to its own last message. For the numbers, GET /apps/{id}/revenue splits money by currency, journey and message, GET /apps/{id}/journeys counts who entered, left early or reached the goal, and GET /workflows/{id}/stats breaks one journey down step by step.

Machine-readable: openapi.json. Console: bolrach.com/push.