Partners
A partner is a business that sells Cowliss inside its own product: one organization per customer, the customer never sees Cowliss, and the partner collects the money through its own billing. Partner access is by arrangement today: write to us, and you get a partner key (cpk_...), shown once.
The partner key is a credential of its own. It is accepted on the partner routes under /v1/partner/ and refused everywhere else, and nothing else (a session, an API key, a pipeline key) is accepted there. Every route that names an organization answers 404 for one you did not create, so the routes never confirm that an organization exists.
One organization per customer
Section titled “One organization per customer”Create the organization when the customer signs up with you:
POST /v1/partner/orgsAuthorization: Bearer cpk_...
{ "data": { "name": "Acme Shop", "externalId": "acme.myshopify.com" } }{ "data": { "orgId": "org_...", "slug": "acme-shop", "pipelineKey": "..." } }Store both. externalId is yours (a shop domain, your own customer id); Cowliss keeps it for your reconciliation and never reads it, so two organizations may carry the same one if that is what your model needs. The pipelineKey is shown here and nowhere else.
From then on you talk to that organization with its pipeline key on the ordinary routes, the way a CI pipeline does, with one difference: in an organization a partner created the pipeline key acts as org:admin everywhere a signed-in admin would, because nobody can sign in. A typical setup, all with the pipeline key: create the app and read its api source (apps.create), mint an ingestion key for your product’s servers (settings.apiKeys.create), claim and verify a sending domain (domains.create, domains.verify), install the automations and templates in one call, and turn the automations on (automations.status). GET /v1/partner/orgs/{id} reads the organization back: its slug and name, your externalId, its plan and whether it is suspended.
Install a whole set in one call
Section titled “Install a whole set in one call”A push is not only for a repo. POST /v1/pushes (pushes.create) takes every resource inline and writes them in one transaction, so the fixed set of automations and templates your product ships lands in a new organization with one request, all of it or none of it:
POST /v1/pushesAuthorization: Bearer <the organization's pipeline key>
{ "data": { "orgId": "org_...", "appId": "app_acme-shop", "automations": [ { "key": "welcome", "definition": { "trigger": { "event": "customer.created" }, "purpose": "transactional", "description": "Welcomes a new customer a day after they sign up.", "steps": [ { "id": "wait-a-day", "type": "sleep", "duration": "1d" }, { "id": "welcome", "type": "sendEmail", "template": "welcome", "props": { "firstName": { "var": "profile.traits.firstName" } } } ] } } ], "templates": [ { "key": "welcome", "template": { "kind": "liquid", "subject": "Welcome to Acme Shop", "purpose": "transactional", "html": "<p>Hi {{ firstName | default: \"there\" }},</p><p>Thanks for signing up.</p>" } } ], "purposes": [{ "key": "marketing.newsletter", "label": "Newsletter" }], "events": ["customer.created", "order.*"] }}orgIdandappIdname where it lands. The pipeline key already decides the organization, so anorgIdthat disagrees with it is refused rather than followed.automationscarry the definition as JSON.templatescarry Liquid HTML ("kind": "liquid",subject,purpose,html, optionalpreviewandtags) or a doc. AsendEmailmust name a template in the same body or one the app already has, a template’s purpose must be one the body or the app declares, and everyfrommust be on a domain the organization verified. Any of these refuses the whole push with 422 and writes nothing.purposespresent adds and updates the ones listed;eventspresent replaces the app’s event list. Leave either out and the app keeps what it has.- The answer is 201 with a version for every automation and template whose content changed. Sending the same body again changes nothing and files no version, so your install handler can retry it.
A push adds and updates and never deletes, and it never turns an automation on: call automations.status after it. POST /v1/pushes/plan takes the same body and answers what the push would change without writing anything.
When your product ships a new version of the set, push it again. If the customer edited one of those templates or automations in the dashboard since, the push is refused with 409 conflict, and the plan names each one, so your upgrade does not silently undo their change. To accept specific conflicts, send them back as confirmed (each kind, key and updatedAt as the plan returned them): a conflict not in that list, or edited again since, is still refused. Set "force": true in data when your product’s copy should win regardless.
Your own unsubscribe
Section titled “Your own unsubscribe”When marketing consent lives in your product (a Shopify app reads Shopify’s customer email marketing consent), you can send the recipient there to leave, instead of to Cowliss. Name the profile trait that holds each recipient’s unsubscribe URL on the app, once, at install:
PATCH /v1/apps/{id}Authorization: Bearer <the organization's pipeline key>
{ "data": { "unsubscribeUrlTrait": "shopifyUnsubscribeUrl" } }The same with the CLI is cow apps update <appId> --unsubscribe-url-trait shopifyUnsubscribeUrl, and null (or an empty string) clears it. Then put the URL on each profile with your ordinary identify, as the trait shopifyUnsubscribeUrl.
From then on, a marketing email to a recipient who carries that trait links to your URL and nowhere else: the footer’s unsubscribe link, the unsubscribeUrl and unsubscribeAllUrl link props (both are your URL as it is) and the one-click List-Unsubscribe header all carry it, and the footer offers no preferences page, since Cowliss’s would change consent behind your record’s back. The URL is used only when it is https on a public host and at most 868 characters long, the most a mail header carries. Any other value, or no value, and that email carries Cowliss’s own links, with nothing reported. Emails under a transactional name keep Cowliss’s links, because your URL leaves all marketing at once, and bare transactional email carries no unsubscribe at all.
Your URL must do two things:
- Answer GET with a page that offers the change, and change nothing on a GET. Mail scanners open every link in a message, so a URL that acts on GET unsubscribes people who never clicked.
- Make the change on the one-click POST (RFC 8058: a
POSTto the same URL with the form bodyList-Unsubscribe=One-Click). Gmail and Yahoo require one-click unsubscribe on bulk mail and send that POST when the recipient presses their own Unsubscribe button, so a URL that is only a page fails that button.
Cowliss records nothing when someone leaves through your URL. Your system records the change, and your next identify brings it back (consent false for the purpose), which is what stops Cowliss’s sends. What follows from that:
- A spam complaint, or an unsubscribe the mailbox provider reports, still reaches Cowliss and stops sending to that address, but your system never hears of it.
- Cowliss’s own unsubscribe counts (a broadcast’s “unsubscribed”, the reason sends stopped on a user’s timeline) stay empty for leaves that went through your URL. Read your system’s numbers instead.
- Email sent before you set the trait keeps Cowliss’s links, and a click on one changes consent in Cowliss alone.
Plan and overage
Section titled “Plan and overage”The plan is yours to set, and it wins over anything else:
PUT /v1/partner/orgs/{id}/plan{ "data": { "plan": "plus", "overageAllowed": true } }Included usage, app and sending-domain limits per plan are the same as on the billing page. With overageAllowed, usage past the included amount is counted and never refused: nothing stops at the credit, nothing is charged to a wallet, and the billing summary (GET /v1/billing, with the pipeline key) carries the amount as overageMicros, with the closed month before it under previousMonth, for you to bill through your own channel after each UTC month rolls over. Without it the organization stops at its included usage like any other with an empty wallet. A partner-managed organization never gets a Stripe customer: its top-up and subscription routes answer 409 partner_managed.
Suspend and restore
Section titled “Suspend and restore”POST /v1/partner/orgs/{id}/suspendPOST /v1/partner/orgs/{id}/restoreSuspended means: ingestion answers 403 org_suspended (provider webhooks included), no automation starts a run (runs already in flight finish with every send recorded as skipped_suspended), nothing is sent, and broadcasts.send answers 403 org_suspended. Data is kept. Restore clears the suspension, and automations that are on trigger again. Both are no-ops when already in that state, so your uninstall and reinstall handlers can call them without checking first.
Delete
Section titled “Delete”DELETE /v1/partner/orgs/{id}Answers 202 at once and runs the deletion in the background: the organization is suspended, its keys revoked, every run cancelled, every row and event removed, sending domains released, and the organization itself deleted last. It cannot be undone. A second call while it runs, or after it finished, answers the same.
For a per-person request inside an organization (a data export, a right-to-be-forgotten request), use the ordinary user routes with the pipeline key: users.export and users.erase.
The order is yours
Section titled “The order is yours”Cowliss enforces no ordering between these operations. A marketplace app would typically create on install, set the plan when the customer picks one, suspend on uninstall, restore on a reinstall inside its grace window, and delete when the platform asks for the customer’s data to go.