CRM Bonus Push — Partner FAQ

Answers to integration questions raised by the CRM team (2026-09-17), sourced from the current implementation. Companion to crm-bonus-e2e-testing-guide.md.

Reference points in code:

Concern Where
Endpoint, accepted fields, responses app/controllers/platform/api/v1/bonuses_controller.rb
Contact/session resolution, delivery app/services/bonuses/push_service.rb
Per-session conversation visibility app/controllers/api/v1/widget/base_controller.rb
Card rendering, image box, expiry app/javascript/shared/components/ChatCard.vue
Collapsed-view preview text app/javascript/widget/helpers/messagePreview.js

1. Campaign reports “success” but the message never arrived

Case: CJ ID 121852 · Partner ID 1 · Campaign ID 823287 — every campaign status reads success, no Hoory message received.

Our only success response is:

200 {
  "status": "created",
  "conversation_id": 92, "message_id": 2968,
  "deliveries": [{ "conversation_id": 92, "message_id": 2968 }]
}

conversation_id / message_id are the first delivery. deliveries lists every copy written — see item 3 for when there is more than one.

Everything else is a 422, not a 5xx — so a CRM that only checks “did the request complete” will record it as success:

Response Meaning
422 contact_not_found Identifier unknown to Hoory. Usually the push fired before login registered it.
422 no_widget_session Contact exists but has never opened the web chat.
422 validation_failed Missing/malformed field — details names it.
422 inbox_disabled The chat channel is switched off.
422 ambiguous_identifier Identifier exists in more than one Hoory account; we refuse to guess.
401 Non permissible endpoint Valid token, but not the crm_bonus application token.

Even a created response is not proof the user saw it

One contact can hold several widget sessions (contact_inboxes), each with its own active conversations. As of now the bonus is delivered to only one of them — the most recent session, into its latest conversation. A created response guarantees the card was written; it does not guarantee the user is looking at the thread it was written into. See §3.

Why this campaign differs from the CJ

The CJ to the same user works (text renders, firstname variable resolves), so the identifier and the widget session are fine. That points at the campaign path itself — a different token, environment, identifier field, or a send that fired before login.

What we need to trace it

  • The exact request body sent
  • The exact HTTP status and response body Hoory returned
  • The identifier value
  • The timestamp (and conversation_id / message_id if one came back)

Please log Hoory’s response body per send. With the status and body recorded, this whole class of issue becomes self-diagnosing on your side.


2. Image size limits

There is no size limit on Hoory’s side — we never receive the file, only image_url. The end user’s browser loads it, so your CRM’s 5 MB cap is the only hard limit.

Requirements

  • Must be http or https — anything else is rejected with 422 validation_failed
  • Must be publicly reachable — an internal CRM URL renders as a broken image

Render box (ChatCard.vue)

Property Value
Card width max 220 px
Image width: 100%, max-height: 150px, object-fit: contain

Recommended: ~440 × 300 px (2× for retina), roughly 3:2 landscape, under ~500 KB. Portrait and square images are letterboxed inside the 220 × 150 box. Clicking the image opens the full-size original in a new tab.


3. Mobile — message not visible after login

Applies to the web widget in a mobile browser.

Because only the identifier is sent (no identifier_hash), each browser is a separate unverified session, and a session can see only the conversations created in it. The contact accumulates multiple sessions, so Hoory has to choose which one receives the card. The rule, in order:

  1. Every session whose widget is open right now (socket connected within the last 90s), up to 3 in parallel. Desktop and phone both open → both get a copy, and deliveries lists each one. Claim links are identical, so double-claim protection stays on your side. Verified sessions share one view, so they count as one screen and get one copy between them.
  2. Otherwise a verified session (identifier_hash sent) — it can see every verified session’s threads.
  3. Otherwise the session the customer most recently opened a conversation in.
  4. Otherwise the newest session.

Consequence:

  • A push while the customer is on the site lands on the device they are using
  • A push while they are away lands in the session they last used, and shows when they return there
  • A copy is never written into a session that is neither open nor the last used one

The only way to share conversations across devices today is to send identifier_hash (HMAC of the identifier, computed server-side with the inbox key) alongside setUser. That marks sessions as verified, and verified sessions of the same contact share one view. Hoory can provide the key and a snippet.

Without it, per-device isolation is expected behavior, not a bug; the rule above only decides where a card goes, it cannot make one session see another’s history.


4. Message starts with “Bonus offer”, which is not in the template

That text was Hoory’s, not yours. It has been removed.

Collapsed views — the unread-message popup and the conversation-list row — have no plain text to preview for a card message, because the content lives in the card structure. The widget used to prefix its own Bonus offer label there. It now shows only your copy:

Card Preview shown
With title <your title>
No title, with description <your description>
No title, no description, with CTA <your cta_text>
Image only a generic media marker (📑)

Inside the opened card only your content is rendered, as before.


5. What expires_at (validity period) actually does

It is not a display duration. The card stays in chat history permanently.

What it does:

  • After that moment the CTA button is hidden and the card shows “Expired”
  • It is evaluated by the user’s browser clock
  • Format: ISO-8601 UTC, e.g. 2026-08-20T12:00:00Z
  • It must be in the future at push time — a past value is rejected with 422 validation_failed, details: ["expires_at"]

“Message disappears after X” does not exist today; that would be a new feature.


6. Subject filled in the template, absent from the delivered message

Expected — the Hoory bonus API has no subject field. Accepted fields:

Field Required Notes
identifier Yes Your user id, already registered on a widget session
title No† Card heading, plain text
description No† Markdown
image_url No† http/https
cta_url No† http/https
cta_text No Button label, defaults to Claim; only with cta_url
tracking_url No Must accept POST; only with cta_url
expires_at No ISO-8601 UTC, must be in the future

† At least one of title, description, image_url, cta_url is required.

Anything else in the request body is dropped silently — which is why there is neither an error nor any text. Map the template’s subject to title (it renders as the card heading). This is a CRM-side mapping fix.


Open items for the CRM team

  • Campaign 823287: request body, Hoory status + response body, identifier, timestamp
  • Bonus offer preview label — removed
  • Confirm whether identifier_hash will be sent, to make bonuses cross-device