Files
getcargohq__cargo-skills/cargo-storage/references/examples/ingest-webhook.md
replicas-connector[bot] 010ae38779 Playbooks for parallel and exa, and a 20x price error in the stage map (#107)
* Playbooks for parallel and exa, and a 20x price error in the stage map

Section 11 claims every credits-based provider in the catalog has a playbook.
Nine do not: brightData, builtwith, datachimp, exa, openRouter, parallel,
proxycurl, sillage and x. This adds the two that matter most for research and
leaves the claim to be squared with the other seven separately.

Both are worth having because both beat what the recipes currently reach for.
parallel.extract reads a known URL for 0.025, half of firecrawl.scrape.
parallel.createTask is the only action in the catalog that fills a
caller-supplied output schema, and at processor lite it is 0.125 against
linkup.instruct at 1. exa.search is the only rung with a document-type category
filter and publication-date bounds, which is what makes it worth 0.175 fixed
plus 0.025 an item over a 0.05 flat search.

Every action, input and price here was read from the live integration catalog
rather than from documentation, per the lesson this pack keeps relearning: a
capability claim needs the implementation, not the docs index.

That is also how the error surfaced. references/stage-action-map.md priced
serper.search at 1. It is 0.05, and provider-playbooks/serper.md had it right
throughout, so the map was steering agents away from the cheapest search rung by
a factor of twenty. alternatives.md carried the same wrong number for
serper.searchPlaces. Both corrected, and the correction is stated in the table
rather than made silently.

The Web research table is rebuilt around the actual decision: known URL means
parallel.extract, plain keyword means serper, document-type or date filter means
exa, structured output means parallel.createTask at lite.

The createTask ladder gets its own table and a warning. processor is required
with no default, and the top rung is 60 credits per record, which is more than a
full contact waterfall.

Co-Authored-By: Auree Aubert <16732193+aureeaubert@users.noreply.github.com>
Co-Authored-By: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com>

* Ban datachimp in code, and close three of the playbook gaps

Two things, both about the same claim: section 11 said every credits-based
provider has a playbook, and nine did not.

Datachimp is removed everywhere and cannot come back. Aurelien's instruction,
2026-08-15. It appeared in the generated catalog and in two lines of
cargo-storage prose. Deleting those would have lasted until the next --refresh,
so the removal is enforced in code instead: a DENIED_INTEGRATIONS set filters it
out of the catalog on both write and read, and skills-lint fails on the string
anywhere in the repo, which is what catches the hand-written half no generator
touches. It was also an integration with zero callable actions, so nothing was
lost.

Three playbooks added, each earning its place on price rather than on coverage.
builtwith's getDomainSummary is FREE and answers most stack questions, so the
playbook's whole argument is that it runs in front of enrichDomain (1); the
current recipes reach straight for a paid rung. sillage's searchLeads is also
free and is the only free signal rung in the catalog, which makes it first on
any signal question by definition. x is fourteen actions at 0.02, the cheapest
signal surface there is, and the playbook spends most of its length on why that
is dangerous rather than on how to use it.

Three are deliberately not written and the claim now names them instead of
overstating. openRouter exposes a model lister and no credits-based actions, so
there is nothing to document. proxycurl is excluded on instruction. brightData
is excluded because its entire action set is consumer social-platform scraping
(Instagram, TikTok, Facebook), which acceptable-use already refuses for person
targeting: documenting it as a GTM provider would contradict the gate two files
away.

Every action, input and price read from the live integration catalog.

Co-Authored-By: Auree Aubert <16732193+aureeaubert@users.noreply.github.com>
Co-Authored-By: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com>

* x: a cost table a validator can read, not a grouping a human can

The action list was grouped by input shape, which reads well and parses to
nothing. Every other playbook here carries `| action | cost | inputs | use for |`
and the downstream validator in getcargohq/gtm-skills asserts a skill's quoted
price against that column, so a skill calling any x action would have failed
with "not in that provider's cost table" and sent someone hunting for a missing
price rather than a missing table shape.

The four engagement-list actions keep their warning in the Use for column
instead of only in the anti-patterns, since that column is what a reader scans.

Co-Authored-By: Auree Aubert <16732193+aureeaubert@users.noreply.github.com>
Co-Authored-By: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com>

* Install the whole bundle by default, and stop hand-counting installs

skills.sh counts per skill, and one `skills add` sends a single event carrying
every skill it installed. So the command in the README decides how many counters
a real install moves. `--all` is now the documented form: it is shorthand for
`--skill '*' --agent '*' -y` and behaves the same in a terminal, in CI, and
inside an agent, where a bare add would drop into an interactive picker.

Two stale hand-maintained facts removed rather than corrected, which is the same
call this repo made about "sixteen skills" in the CLI help.

The install total read "installed 54,885 times". The live figure is 58,245
across the seventeen skills. A number in prose that only moves when somebody
remembers to move it will be wrong again next week, and this one is a public
claim about traction, which is the worst kind to be casually wrong about.

The gtm-skills section listed twelve skills by name. That repo has grown past
twelve, so the list now points at its README instead: a hand-maintained copy of
someone else's list is a copy that goes stale.

Co-Authored-By: Auree Aubert <16732193+aureeaubert@users.noreply.github.com>
Co-Authored-By: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com>

---------

Co-authored-by: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com>
Co-authored-by: Auree Aubert <16732193+aureeaubert@users.noreply.github.com>
2026-08-14 23:58:36 -07:00

6.7 KiB

Ingest models — get the webhook URL and POST records

Some models are fed by pushing records to Cargo instead of Cargo pulling them. Their extractor has mode.kind === "ingest" — the canonical one is the http integration's listenHook ("Listen webhook"), but the same mechanism backs storeleads.listenList, rb2b.listenProfiles, albacross.listenWebsiteVisits, and others.

The app shows a Webhook URL on the model's settings screen. There is no CLI command and no API field that returns it — the app builds the string client-side. You can build the exact same string from data the CLI already exposes.

The URL

<baseUrl>/v1/models/<model-uuid>/records/ingest?token=<api-token>
  • <baseUrl> — cargo-ai whoami → .baseUrl (e.g. https://api.getcargo.io). Note the path is /v1/models/..., not /v1/storage/models/....
  • <model-uuid> — the ingest model's UUID.
  • <api-token> — any workspace API token. The token may carry zero permissions; this route is explicitly allowed for permission-less tokens so the URL can be handed to a third-party system safely. The app auto-creates one named Quick access for exactly this.

token can also be sent as an Authorization: Basic <token> header instead of a query param — preferable when the receiving system supports custom headers, since a query param lands in logs.

Derive it

# 1. Find the model and confirm it is an ingest model
cargo-ai storage model get <model-uuid> | jq '{uuid, slug, extractorSlug, kind, connectorUuid}'

# 2. Confirm the extractor's mode is "ingest" (and NOT autoIngest — see below)
cargo-ai connection integration get http | jq -c '.integration.extractors.listenHook.mode'
# → {"kind":"ingest"}

# 3. Pick or create a token (the raw value is on the list response)
cargo-ai workspaceManagement token list | jq -r '.tokens[0].token'
cargo-ai workspaceManagement token create --name "Webhook — <model-slug>" | jq -r '.token.token'

One-liner that assembles it:

MODEL_UUID=<model-uuid>
BASE=$(cargo-ai whoami | jq -r '.baseUrl')
TOKEN=$(cargo-ai workspaceManagement token list | jq -r '.tokens[0].token')
echo "$BASE/v1/models/$MODEL_UUID/records/ingest?token=$TOKEN"

Token values are secrets. Print the URL for the user to copy; don't write it into a file, a commit, or a report.

Skip models where Cargo owns the hook

Some ingest extractors set autoIngest: true — Cargo registers the webhook with the provider itself during setup (calendly, smartlead, instantlyV2, heyReach, and cargo's own signal extractors). The app hides the URL for those, and handing it out is wrong: the provider is already pointed at it.

Check before showing anything:

cargo-ai connection integration get <integration-slug> \
  | jq -c '.integration.extractors["<extractor-slug>"].mode'
# {"kind":"ingest"}                    → manual: show the URL
# {"kind":"ingest","autoIngest":true}  → Cargo owns it: don't show the URL
# anything else (fetch/…)              → not an ingest model at all

List every ingest extractor an integration has:

cargo-ai connection integration get <integration-slug> \
  | jq -c '.integration.extractors | to_entries
           | map(select(.value.mode.kind=="ingest")) | map({(.key): .value.mode})'
# calendly → [{"fetchEvents":{"kind":"ingest","autoIngest":true}}]

POST records

The body is either one flat object or an array of flat objects — each object becomes one record, and its keys become columns. Max 100 records per request.

# one record
curl -X POST "$BASE/v1/models/$MODEL_UUID/records/ingest?token=$TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","company":"example.com"}'

# many records
curl -X POST "$BASE/v1/models/$MODEL_UUID/records/ingest?token=$TOKEN" \
  -H "Content-Type: application/json" \
  -d '[{"email":"ada@example.com"},{"email":"grace@example.com"}]'

# token as a header instead of a query param
curl -X POST "$BASE/v1/models/$MODEL_UUID/records/ingest" \
  -H "Content-Type: application/json" \
  -H "Authorization: Basic $TOKEN" \
  -d '{"email":"ada@example.com"}'
# all → 200 {"message":"OK"}

The endpoint is insert-only. Do not send an envelope like {"kind":"insert","records":[…]} — there is no unwrapping, so you get one useless row with a kind column and a records column holding the stringified array. The {kind: insert|update|remove, records: […]} shape belongs to the extractor's internal contract, not to this HTTP body; update and remove are not reachable through the webhook.

For http.listenHook, the model's id column is _ingest_id (a UUID generated server-side — never send it; the extractor rejects inserts that carry one) and its title column is _emitted_at.

Then confirm the rows landed (storage queries are free):

cargo-ai storage query execute "SELECT * FROM <dataset-slug>.<model-slug> LIMIT 10"

Create an ingest model from scratch

model create has no --connector-uuid — the API infers the connector from the dataset. Every connector automatically owns exactly one kind: "connector" dataset, so the flow is: create the connector, find its dataset, create the model in it.

# 1. Connector (slug must be snake_case: /^[a-z0-9]+(_[a-z0-9]+)*$/)
cargo-ai connection connector create \
  --name "Inbound leads" --slug inbound_leads \
  --integration-slug http --config '{}' | jq -r '.connector.uuid'

# 2. Its dataset — dataset list takes no --connector-uuid filter, so filter locally
cargo-ai storage dataset list \
  | jq -c --arg c <connector-uuid> '.datasets[] | select(.connectorUuid==$c) | {uuid, slug}'

# 3. The model
cargo-ai storage model create \
  --slug inbound_leads --name "Inbound Leads" \
  --dataset-uuid <dataset-uuid> \
  --extractor-slug listenHook --config '{}'

The response comes back with kind: "connector", idColumnSlug: "_ingest_id", and titleColumnSlug: "_emitted_at". Columns are then created dynamically from the keys of whatever you POST — you don't declare them up front. Query it as <connector-slug>.<model-slug>.

Notes

  • The endpoint answers webhook handshakes out of the box — Slack url_verification, generic ping, Microsoft ?validationToken=, Salesforce SOAP ack, and Meta's hub.challenge on GET — so most providers validate without extra work.
  • Ingest models can't be refreshed or scheduled; data only arrives when something POSTs. model refresh / --schedule don't apply.
  • A legacy alias POST /v1/workflows/<uuid>/hook still resolves to the same insert (the uuid being the model uuid). Prefer the /v1/models/... form.
  • Because the URL is assembled client-side, it is derived, not returned — if a future CLI release adds a field or a get-webhook-url command, prefer that.