CRM integration
If you run a B2B or SaaS partner program, the conversion that actually matters usually isn’t a self-serve signup — it’s a state change in your CRM. A lead becomes “Sales Qualified”. An opportunity hits “Closed Won”. A trial converts to paid. Without that signal, partners only get paid on the trivial cases and abandon your program.
This doc shows how to wire HubSpot, Salesforce, Pipedrive, or any CRM into OpenPartner so those state changes accrue commission.
The model
OpenPartner already accepts arbitrary conversion events at
POST /attribution/events. To CRM-ify it:
- Mint a scoped API key (
events:write) under Admin → Integrations. One key per integration. - Configure your CRM (or a Zapier / Make workflow in front of it) to
POST to
/attribution/eventswhen the relevant state change fires, with the API key as a Bearer token. - OpenPartner ties the event to the originating click via the
userIdyou assign at signup time, then accrues commission per the bound campaign’s rule.
The key is scoped — leaked, it can fabricate conversion events for your tenant but can’t edit partners, payouts, or campaigns. Worst case you revoke + re-mint.
The payload
curl -X POST https://your-instance/api/attribution/events \ -H "Authorization: Bearer <your-key>" \ -H "Content-Type: application/json" \ -d '{ "userId": "user_or_email_from_your_system", "type": "opportunity_won", "value": 4500.00, "currency": "USD", "externalEventId": "crm:0061k00000ABC:closed_won", "metadata": { "deal_id": "0061k00000ABC", "stage": "Closed Won" } }'Fields:
userId(required) — the same identifier you assigned at signup. Without this OpenPartner can’t stitch the event to a click. If your CRM only knows the contact’s email, use email here as long as you’ve been callingidentify(email)at signup time too.type(required) — any string. Use whatever your team will recognize:opportunity_won,trial_converted,lead_qualified,expansion_signed. The string flows through to the Event row and is visible in your dashboard.value(optional) — decimal amount, as a JSON number. Used by percent-based commission rules to compute the partner’s cut. A quoted merge field ("value": "4500") and an empty one (null) are both refused with400, so leavevalueout of the body when the deal has no amount.currency(optional) — ISO 4217 code, any case. Without it the deal is recorded in your default currency (Admin → Settings → Brand info, USD unless you changed it), so a ₹25,000 deal sent without a currency becomes USD 25,000 on a USD account. Send it whenever you take more than one currency.externalEventId(recommended) — your stable id for this one conversion, such as the deal id plus the stage. CRM workflows retry, and a deal can move back and forth between stages: with the same key, a repeat returns the original result instead of paying a second commission. Keys are unique across your whole account, so use one per stage and prefix them per CRM. See Idempotency.metadata(optional) — free-form JSON. Useful for attaching the source CRM record ID so you can audit later.ts(optional) — ISO timestamp of when the conversion happened. Defaults to now. Use this if your webhook fires async and you want the original event time preserved for attribution-window math.
HubSpot
HubSpot Workflows can POST custom JSON via the Custom code (Node.js) action. The path:
- Create a workflow triggered on the deal stage change you care about
(e.g.
Deal stage = Closed Won). - Add a Custom code (Node.js) action.
- Paste:
const fetch = require('node-fetch');
exports.main = async (event, callback) => { const deal = event.inputFields; const response = await fetch('https://your-instance/api/attribution/events', { method: 'POST', headers: { 'Authorization': 'Bearer <your-openpartner-key>', 'Content-Type': 'application/json', }, body: JSON.stringify({ userId: deal.contact_email, // map to the identifier you used at signup type: 'opportunity_won', // A number; left out when the deal has no amount (null is refused). ...(deal.amount ? { value: Number(deal.amount) } : {}), currency: deal.deal_currency_code || 'USD', externalEventId: `hubspot:${deal.hs_object_id}:closedwon`, // one per deal + stage metadata: { hubspot_deal_id: deal.hs_object_id, stage: deal.dealstage, }, }), }); callback({ outputFields: { status: response.status } });};- Map the workflow’s input fields (
contact_email,amount,hs_object_id,dealstage) so the code can read them.
Salesforce
Salesforce doesn’t have inline custom code in standard Process Builder / Flow, so the cleanest path is Salesforce → Zapier → OpenPartner:
- Create a Zapier zap with Salesforce trigger (e.g. “New Opportunity updated, Stage = Closed Won”).
- Add a Webhooks by Zapier → POST action with:
- URL:
https://your-instance/api/attribution/events - Payload type: JSON
- Headers:
Authorization:Bearer <your-key>Content-Type:application/json
- Data: Map Salesforce fields to
userId,type,value,externalEventId(the opportunity id plus the stage), etc. Check in a test run thatvaluearrives as a JSON number, not as text: a quoted amount is refused with400.
- URL:
For high-volume programs (>100 events/day), an Apex trigger calling
HttpCallout directly is more cost-efficient than Zapier — but Zapier
is fine to start.
Pipedrive / Close / Copper
All three have webhook-out features. Configure the webhook to point at
/api/attribution/events, then either:
- Use the CRM’s native field mapping if it lets you customize the JSON body (Close does, Copper does), or
- Pipe through Zapier / Make for the JSON shape transformation.
Generic Zapier / Make pattern
For any CRM that Zapier or Make supports as a trigger:
- Trigger: the relevant state change in your CRM (deal stage, lead status, etc.)
- Action: Webhooks → POST with the OpenPartner endpoint, key, and JSON shape from the payload section above
- Test: trigger the source event manually, watch the OpenPartner admin’s Review queue and partner’s commission ledger update
Refunds and credit notes
When the CRM records a refund, a credit note or a lost chargeback on a
deal you already reported, send it to
POST /attribution/refunds
with the same events:write key. Point at the conversion by the
externalEventId you reported it with, and key the refund on its own id
(the refund or credit note), so the workflow’s retries record it once:
{ "conversionExternalEventId": "crm:0061k00000ABC:closed_won", "amount": 1500.00, "reason": "partial credit", "externalEventId": "crm:0061k00000ABC:refund:CN-2001"}Leave amount out to refund the whole deal, and send "kind": "chargeback"
for a chargeback. Unpaid commissions are reversed, or reduced to the share
the customer kept. A commission that’s already paid stays paid, and the
refunded share comes off the partner’s next manual or self-hosted Stripe
Connect payout. When the response says actionRequired: true, flag the
deal for someone: the partner was already paid (recover it by hand only
if they’re paid by Stripe Connect on hosted OpenPartner), or a payout was
in flight (retry the same request later).
Identifying the user — the only hard part
The single thing that has to line up between your CRM and OpenPartner
is the userId. The cleanest patterns:
- Use email. Most CRMs have email on every contact. Make sure your
signup flow calls
identify({ userId: email })so OpenPartner stitches by the same value. - Use your auth system’s user ID and sync it to the CRM contact.
HubSpot custom field, Salesforce custom field, etc. Then the CRM
webhook reads the field and uses that as
userId. - Use external IDs. If your CRM and OpenPartner both reference your internal user/account ID via metadata, both webhooks (signup → identify and CRM stage → events) can use it consistently.
If userId doesn’t match what was stamped on the original click’s
identity, the event lands without an attribution and no commission accrues.
The Event row is still inserted (so you can debug), but no partner gets
paid. The fix is always at the identify step — make sure the same
identifier is used at signup and at CRM-event-fire time.
No signup on your site at all? If the lead comes from a form or an
application flow that captures the click id (cref, or your destination’s
{click_id} parameter), store it on the CRM record and report the deal with
POST /attribution/conversions
instead. It needs no userId and no identify step.
What’s not here yet
- Outbound to CRM — pushing partner attribution back into HubSpot /
Salesforce as a custom field on the contact. This is on the roadmap;
the current workaround is a separate Zapier job that reads
OpenPartner’s
/attributionsAPI and pushes to the CRM. - Native connectors — first-class HubSpot / Salesforce apps in their marketplaces. Coming. For now, the patterns above cover 95% of the use case at Zapier’s lower cost.
If you’re integrating a CRM not covered here and the pattern doesn’t fit cleanly, the issue tracker is open.