USE_CASE_ID: apollo_sequence_push NAME: Push qualified leads into an Apollo sequence (HTTP API Call) CATEGORY: Outbound prospecting The user's sequencer is Apollo, and the tail of their chain has to end with "and put this person in that sequence." Floqer has native add-to-campaign actions for smartlead, instantly, lemlist, reply, heyreach, la_growth_machine, outreach, salesloft, salesforge and plusvibe. It has NONE for Apollo. Apollo appears in the action catalog only as a DATA provider (get_employees_by_company_using_apollo, person_enrich_using_apollo, and as a provider inside the email/phone waterfalls) — there is no action that enrolls a contact in an Apollo sequence. So the push must be hand-built on http_api_call, and this file is the recipe. It is a three-action TAIL, not a whole workflow. It bolts onto the end of whatever chain already produced a qualified person (inbound visitor, LinkedIn reactor, ICP list, hiring signal). Everything upstream — sourcing, enrichment, the qualification gate — belongs to that use case; this one starts at "I have a qualified person and their email" and ends at "they are in the sequence." Apollo needs the person to exist as a CONTACT (an owned record in the user's Apollo account) before it will enroll them. A person in Apollo's global database is not a contact. So the tail is always TWO calls, never one: create/dedupe the contact, then enroll the id it returns. The dominant failure mode in this use case is SILENT SUCCESS: Apollo answers HTTP 200, Floqer marks the cell green, and nobody was added. Read §8 before shipping. For routing, file structure, and cross-cutting principles, see https://floqer.com/docs/use-case-catalog.txt. This file does not repeat them. INDEX: 1. When to use / when not to use 2. Inputs and pre-flight clarifications 3. Outputs 4. Workflow design 5. Implementation 6. Best practices 7. Common variations 8. Failure modes and mitigations 9. Related use cases ================================================================================ 1. WHEN TO USE / WHEN NOT TO USE ================================================================================ USE WHEN: - The user's outbound sequencer is Apollo and leads must land in a named Apollo sequence at the end of a Floqer chain. - Apollo is the user's contact system of record and they want Floqer's enrichment written back onto the contact record (title, company, phone, address, labels). - The "sequence" is a holding pen rather than a sender — a 0-step, inactive sequence used as a destination list that a human works from. This is common and fully supported; see the `paused` note in §3. DO NOT USE WHEN: - The sequencer is one Floqer already supports natively (smartlead, instantly, lemlist, reply, heyreach, la_growth_machine, outreach, salesloft, salesforge, plusvibe). Use the native action — it handles auth, retries and field mapping for you. See https://floqer.com/docs/use-case-detail/icp_outbound_prospecting.txt §5 Stage 6. - The user only wants Apollo DATA (emails, phones, firmographics). That is person_enrich_using_apollo / get_employees_by_company_using_apollo, and it does not touch their Apollo account at all. - The user has no qualification gate yet. Build the gate first. This tail writes to a live CRM and enrolls people in outreach; wiring it to an ungated chain means every scraped row becomes a contact. ================================================================================ 2. INPUTS AND PRE-FLIGHT CLARIFICATIONS ================================================================================ INPUTS (from the chain that this tail bolts onto) Required: first_name, last_name (string) — usually from enrich_person_linkedin_profile. email (email) — the address to enroll. From a work-email waterfall, or an inbound form/visitor payload. A qualification verdict upstream to gate on (see §6). Optional but recommended: title, organization_name — from the person scrape (person_current_job_title, current_company_name). company domain, city/state/country, phone — enrich the Apollo contact record. Map them onto the right Apollo keys (§5, field map). CREDENTIALS (gather these BEFORE building — all three are Apollo-side) Apollo API key. The sequence endpoint requires a MASTER key; a scoped key returns 403. Auth is the `X-Api-Key` header (not a bearer token). Sequence id. Read it off the sequence URL in the Apollo UI: app.apollo.io/#/sequences/ Mailbox id (`send_email_from_email_account_id`). REQUIRED by the enroll endpoint even when the sequence never sends anything. Get the list with: GET https://api.apollo.io/api/v1/email_accounts Use the mailbox belonging to the sequence's owner. PRE-FLIGHT CLARIFICATIONS - **Verify the key belongs to the RIGHT Apollo account.** This is the check people skip. `GET /email_accounts` returns the mailboxes on the account the key authenticates — if the addresses are not the user's company, the key is the wrong one. A key from another org authenticates fine (200, `healthy: true`) and cheerfully writes contacts into someone else's Apollo. Observed live: an Apollo tail copy-pasted between two customers' workflows carried the ORIGINAL customer's key, sequence id and mailbox id; every field looked plausible and every call would have succeeded — against the wrong tenant. - **Sending or holding pen?** Ask whether the sequence actually sends. If it is a 0-step/inactive destination list, `sequence_no_email: true` and the resulting `paused` status are correct and expected. If it DOES send, confirm the user wants Floqer enrolling people into live outreach, and confirm which mailbox sends. - **New contacts only, or update existing too?** `run_dedupe` returns an existing contact rather than updating it (§6). If the user expects Floqer's enrichment to land on contacts Apollo already has, the tail needs the PATCH variation in §7 — otherwise those fields silently never get written. - **Labels.** `label_names` is the cheapest provenance you will get (which Floqer flow created this contact). Confirm the label text with the user; the first push CREATES the label in their Apollo. ================================================================================ 3. OUTPUTS ================================================================================ From the upsert call (http_api_call — dynamic schema, discovered on first run): contact (json) — the full Apollo contact record. `contact.id` is the only field the rest of the tail needs. was_existing (boolean) — true when Apollo deduped onto a contact it already had, false when it created one. The natural gate for an "update existing" branch (§7). labels (json) — the labels now on the contact. From the id extractor (formatter): formatted_data (string) — the Apollo contact id, e.g. "6a553a94d4dc490018571021". From the enroll call (http_api_call — dynamic schema): contacts (raw_array) — the contacts actually ADDED. Empty means nobody was enrolled. skipped_contact_ids (json) — the contacts Apollo REFUSED, as {contact_id: reason}. READ THIS. It is the only signal that a green cell added nobody (§8). emailer_campaign (json) — the sequence the contact landed in. emailer_steps, emailer_touches, team, signals_hash — sequence metadata, rarely needed. Enrollment status. A contact added to an INACTIVE / 0-step sequence lands with `status: paused`, not `active`. That is Apollo's behaviour, not an error — the sequence has no steps to run. It does mean that if the user later adds steps and activates the sequence, previously-parked contacts do not start on their own. ================================================================================ 4. WORKFLOW DESIGN ================================================================================ Three actions appended after the chain's qualification gate: ... -> [qualification: llm_web_agents / llm_models] -> [filter: only qualified rows continue] <- gate BEFORE the writes -> http_api_call "Apollo: Upsert Contact" -> format_data_using_js_expression "Apollo Contact ID" -> http_api_call "Apollo: Add to Sequence" Key design decisions: - **Two calls, because Apollo needs a contact id.** The enroll endpoint takes `contact_ids[]`, and only accepts ids of contacts the account owns. You cannot enroll by email. So the contact must be created (or deduped onto) first, and its id threaded into the second call. - **Create with run_dedupe, don't search-then-create.** `POST /contacts? run_dedupe=true` returns the existing contact when one matches (by email/name) instead of creating a duplicate, which collapses the usual search -> branch -> create/update dance into one call. Its limitation is real and is the price of that simplicity: it RETURNS the existing contact, it does not UPDATE it (§6, §7). - **The id extractor is a formatter, not JS logic.** The upsert's `contact` output is a structured object, so the pure Floqer-expression template plucks the id with no JS at all: {{.contact}}?.id Use format_data_using_js_expression (its input field is `input`). formatter_new is NOT addable via the public API — if you are building over the API, reach for format_data_using_js_expression; both accept the same template form. See https://floqer.com/docs/action-detail/format_data_using_js_expression.txt §1. - **The enroll call carries NO body.** Apollo's spec defines no request body for add_contact_ids — every parameter is a QUERY parameter. In Floqer that means `query_params` holds everything and `body` must be an EMPTY ARRAY. A leftover empty body item fails the cell at run time with `Unexpected end of JSON input` (§8). - **Gate before the writes, always.** Both calls mutate the user's Apollo. An ungated tail turns every enriched row into a contact and an enrollment. The filter belongs immediately before the upsert. ================================================================================ 5. IMPLEMENTATION ================================================================================ QUICK REFERENCE — the three actions (substitute your own upstream instance ids): http_api_call "Apollo: Upsert Contact" POST https://api.apollo.io/api/v1/contacts?run_dedupe=true header X-Api-Key; JSON body = the contact fields outputs: contact, was_existing, labels format_data_using_js_expression "Apollo Contact ID" input: {{.contact}}?.id output: formatted_data http_api_call "Apollo: Add to Sequence" POST https://api.apollo.io/api/v1/emailer_campaigns//add_contact_ids ALL params in query_params; body: [] outputs: contacts, skipped_contact_ids, emailer_campaign, ... STAGE 1 — Apollo: Upsert Contact Action: http_api_call (0 credits — you are paying Apollo, not Floqer) Config: { "inputs": { "method": "POST", "endpoint": "https://api.apollo.io/api/v1/contacts?run_dedupe=true", "headers": [ { "name": "Accept", "value": "application/json" }, { "name": "Content-Type", "value": "application/json" }, { "name": "X-Api-Key", "value": "" } ], "body": [ { "name": "", "type": "raw_json", "value": "{ \"first_name\": \"{{.first_name}}\", \"last_name\": \"{{.last_name}}\", \"email\": \"{{.work_email}}\", \"organization_name\": \"{{.current_company_name}}\", \"title\": \"{{.person_current_job_title}}\", \"website_url\": \"{{.current_company_domain}}\", \"present_raw_address\":\"{{.city}}, {{.state}}, {{.country}}\", \"mobile_phone\": \"{{.phone_number}}\", \"label_names\": [\"Floqer: \"] }" } ] }, "continue_workflow_if_action_fails": true } FIELD MAP — Apollo accepts ONLY these keys on a contact. Anything else is silently dropped from the request; there is no error and no warning: first_name, last_name, organization_name, title, account_id, email, website_url, label_names[], contact_stage_id, present_raw_address, direct_phone, corporate_phone, mobile_phone, home_phone, other_phone, typed_custom_fields, run_dedupe ⚠ THERE IS NO `phone` KEY. Phones are TYPED: `mobile_phone` (a personal / mobile waterfall result), `direct_phone`, `corporate_phone`, `home_phone`, `other_phone`. Sending `"phone": "..."` is accepted, ignored, and the number is thrown away — an entire paid phone-waterfall step silently amounting to nothing. Observed live. Apollo returns phones normalized into `contact.phone_numbers[]`, not on the key you sent. STAGE 2 — Apollo Contact ID Action: format_data_using_js_expression Config: { "inputs": { "input": "{{.contact}}?.id" } } The pure template form — no `return`, no IIFE. The template engine follows the optional-chain into the structured `contact` output. Note the input field is `input`; PATCHing `data_formatter` returns 200 with an `unknown_field` warning and silently changes nothing. ORDERING — this ref cannot be wired until the upsert has RUN. http_api_call has no output schema until it has seen a real response, and a {{ref}} to a field that does not exist yet binds EMPTY, permanently. The sequence is: 1. Configure Stage 1. 2. Run Stage 1's CELL on one real row (Run Action, run_next_action: false). 3. GET .../actions//outputs -> `contact` now exists. 4. THEN configure Stage 2 and Stage 3. Building 1->3 in one pass leaves the id ref resolving to nothing, and the enroll call posts an empty contact_ids[] forever. STAGE 3 — Apollo: Add to Sequence Action: http_api_call Config: { "inputs": { "method": "POST", "endpoint": "https://api.apollo.io/api/v1/emailer_campaigns//add_contact_ids", "query_params": [ { "name": "emailer_campaign_id", "value": "" }, { "name": "contact_ids[]", "value": "{{.formatted_data}}" }, { "name": "send_email_from_email_account_id", "value": "" }, { "name": "sequence_no_email", "value": "true" }, { "name": "sequence_unverified_email", "value": "true" }, { "name": "sequence_job_change", "value": "true" }, { "name": "sequence_active_in_other_campaigns", "value": "true" }, { "name": "sequence_finished_in_other_campaigns", "value": "true" }, { "name": "sequence_same_company_in_same_campaign", "value": "true" }, { "name": "contacts_without_ownership_permission", "value": "true" }, { "name": "add_if_in_queue", "value": "true" }, { "name": "contact_verification_skipped", "value": "true" } ], "body": [], "headers": [ { "name": "Content-Type", "value": "application/json" }, { "name": "Cache-Control","value": "no-cache" }, { "name": "X-Api-Key", "value": "" } ] }, "continue_workflow_if_action_fails": true } THE GUARD FLAGS ARE THE WHOLE BALLGAME. Each `sequence_*` / `contacts_*` / `add_if_*` flag defaults to FALSE, and each one is a reason Apollo will refuse a contact — while still returning HTTP 200. Left at their defaults, Apollo drops anyone who has changed jobs, or is already in another sequence, or lacks a verified email, and reports it only inside `skipped_contact_ids`. Observed live on the very first test: 200, empty `contacts`, and "skipped_contact_ids": { "67acf9cb...": "contacts_with_job_change" } Set every flag above to "true" unless the user has a stated reason to suppress a category (e.g. they genuinely do not want people who are already in another sequence). `body: []` — an empty ARRAY. Not an empty raw_json item. See §8. VERIFYING THE PUSH (do this once, on one row, before any Run All) Read the cell, not just the status. `contacts` should contain the person and `skipped_contact_ids` should be `{}`. Then confirm in Apollo: POST https://api.apollo.io/api/v1/contacts/search { "q_keywords": "" } and look at `contact_campaign_statuses[]` — it should name your sequence id with a status of `paused` (0-step sequence) or `active`. Reversing a test push (leaves no trace): POST https://api.apollo.io/api/v1/emailer_campaigns/remove_or_stop_contact_ids ?emailer_campaign_ids[]=&contact_ids[]=&mode=remove There is no equivalent for contact CREATION — a test contact stays in the user's Apollo. Prefer testing the enroll call with a contact that already exists in the account. ================================================================================ 6. BEST PRACTICES ================================================================================ - **Read skipped_contact_ids on every run, not just the first.** It is the only place Apollo admits it dropped someone. A green cell, an empty `contacts` array and a populated `skipped_contact_ids` is what "it silently stopped working" looks like in this integration. - **run_dedupe is not an upsert.** Apollo's own wording: it "will return the existing contact instead of creating a duplicate." It does not merge your fields into that contact. Every enrichment field in Stage 1 — phone, address, website, labels — therefore lands ONLY on contacts Apollo creates fresh. If the user's Apollo already holds most of their market, most rows will write nothing. Use the §7 PATCH variation when updating matters, and set expectations explicitly when it does not. - **Gate before the writes.** Both calls mutate a live CRM. Put the qualification filter immediately before the upsert, and confirm with the user what happens to unqualified rows (dropped silently, or written to Apollo unsequenced as a research record — both are legitimate, but it must be a decision, not an accident). - **Label every contact with its source flow.** `label_names: ["Floqer: Website Visitors"]` costs nothing, creates the label on first write, and is the only way the user can later tell which Floqer chain produced a contact. Without it, Floqer-created contacts are indistinguishable from hand-added ones. - **One key per Apollo tenant, and check it.** Before the first run, `GET /email_accounts` and confirm the mailboxes belong to the customer. Two minutes here prevents writing a customer's leads into another customer's Apollo. - **Test the enroll call on a contact that already exists.** Apollo has no contact-delete API, so a throwaway test contact is permanent. Enrolling an existing contact is fully reversible (`remove_or_stop_contact_ids`), so validate the plumbing that way and let the first REAL row create the first new contact. ================================================================================ 7. COMMON VARIATIONS ================================================================================ - **Actually update existing contacts (PATCH branch).** `run_dedupe` gives you `was_existing`. Gate a fourth action on it: run_if: {{.was_existing}} is true PATCH https://api.apollo.io/api/v1/contacts/{{.formatted_data}} body: the same field map as Stage 1, minus run_dedupe Now new contacts get their fields at creation, and existing contacts get them written on the PATCH. Use this whenever the user expects Floqer's enrichment to show up on contacts Apollo already had. - **Inbound website visitors.** Same tail, different upstream: the identity fields come from the visitor payload (businessEmail, company, companyDomain, location) rather than a scrape, and the phone comes from a personal-phone waterfall -> `mobile_phone`. See https://floqer.com/docs/use-case-detail/icp_outbound_prospecting.txt for the inbound-routing shape. - **Intent / engagement triggers (LinkedIn reactors, hiring signals).** Same tail. Source the reactors or signal, qualify, then push. Label the contacts with the trigger ("LinkedIn Reactor") so the rep opening Apollo knows WHY this person is in front of them. - **A holding pen instead of a sender.** A 0-step, inactive sequence is a perfectly good destination list — contacts land `paused` and a human works them. Nothing about the config changes; just do not promise the user that Apollo will start emailing. - **Batching several contacts per call.** `contact_ids[]` is an array, so one call can enroll many. Only worth it when pushing from a sheet whose rows are accounts (many contacts per row); the per-row tail described here sends one id per call, which is simpler and per-row observable. - **Enroll by label instead of by id.** add_contact_ids accepts `label_names[]` in place of `contact_ids[]`. Useful for a one-off bulk enroll of everything Floqer has tagged, run manually — not for a per-row chain. ================================================================================ 8. FAILURE MODES AND MITIGATIONS ================================================================================ - **HTTP 200, green cell, nobody added.** THE failure mode of this use case. Apollo enforces its guard flags by SKIPPING contacts, not by erroring: the response is 200, `contacts` is empty, and the reason sits in `skipped_contact_ids` ({id: "contacts_with_job_change"} and friends). Floqer marks the cell complete. The workflow looks perfect and the sequence stays empty. Mitigation: set every guard flag in §5 to "true", and check `skipped_contact_ids` on the first run and periodically after. If the enroll action's schema predates the fix and does not expose skipped_contact_ids, see the schema-lock note below. - **`phone` silently dropped.** Apollo has no `phone` key — only mobile_phone / direct_phone / corporate_phone / home_phone / other_phone. A `"phone"` key is accepted and ignored, so a paid phone waterfall upstream produces nothing on the contact, with no error anywhere. Mitigation: map the waterfall onto `mobile_phone` (personal) or `corporate_phone` (company). Verify by reading `contact.phone_numbers[]` on the response. - **`Unexpected end of JSON input` on the enroll cell.** http_api_call JSON.parses each `body[]` item. An item with `value: ""` — the placeholder the UI leaves behind, or the residue of converting a body-based call to a query-param one — throws at run time. The action PATCHes 200 and looks fine in GET. Mitigation: `body: []`. - **The wrong Apollo account.** An Apollo tail copy-pasted from another workflow brings its API key, sequence id and mailbox id with it. Every one of those is a plausible-looking opaque string, and the calls all succeed — against the wrong tenant. Mitigation: the `GET /email_accounts` check in §2. Do it before the first run, and re-do it any time a workflow is duplicated. - **Output schema is frozen from the first response.** http_api_call derives its outputs from the first response it ever sees and does not re-expand them. An enroll action that first ran against a different sequence (or before the guard flags were fixed) can be stuck exposing only `contacts` — so `skipped_contact_ids` is invisible no matter how many times it runs. Mitigation: delete the action and re-add it, then let it rediscover the schema on its next run. Safe when it is terminal (nothing wired off its outputs); if something IS wired off it, re-point those refs to the new action_instance_id afterwards. - **403 on the enroll call.** add_contact_ids requires a MASTER Apollo key. A scoped key enriches and creates contacts happily, then 403s here. Mitigation: use a master key for this tail. - **The id ref resolves empty.** Stage 2/3 wired before the upsert had ever run: the `contact` output did not exist, so the ref bound to nothing and stays bound to nothing. The enroll call then posts an empty `contact_ids[]` and adds no one, forever. Mitigation: the run-then-wire ordering in §5 Stage 2. If you suspect a stale binding, re-PATCH the consuming action after the producer's outputs appear — a no-op re-PATCH re-resolves the reference. - **Contacts land `paused` and never send.** Correct behaviour for a 0-step / inactive sequence. It becomes a problem only if the user later activates that sequence and expects the already-parked contacts to start. Mitigation: say so up front. To enroll as active, the sequence must have steps and be active; `status` on the enroll call accepts `active` | `paused`. ================================================================================ 9. RELATED USE CASES ================================================================================ - icp_outbound_prospecting — the full cold-outbound chain (source -> enrich -> verify -> sequence). Its Stage 6 pushes to a sequencer via a NATIVE action; when the sequencer is Apollo, swap that stage for this tail. - still_at_company_outbound_gate — insert it immediately before this tail. It drops people who have left the company they were sourced for, so you neither create a stale contact nor enroll them. It also removes the most common `contacts_with_job_change` skip at the source. - hiring_signal_outbound — a natural upstream. Hiring signals produce qualified contacts at target accounts; this tail is where they land. - crm_pull_enrich_update — when Apollo is the system of record and the goal is CRM hygiene rather than outreach, the PATCH variation (§7) is closer to what the user wants than the enroll call. ================================================================================ This file is maintained manually. Last updated: 2026-07-13. Use case catalog: https://floqer.com/docs/use-case-catalog.txt Related action details: https://floqer.com/docs/action-detail/http_api_call.txt https://floqer.com/docs/action-detail/format_data_using_js_expression.txt https://floqer.com/docs/action-detail/filter.txt https://floqer.com/docs/action-detail/enrich_person_linkedin_profile.txt