Partner API — sponsored advertiser enrollment
Enroll your advertisers in independent measurement from Provalytics. You send us the advertiser’s details and we return a sign-up link. The advertiser accepts the terms and connects Google Analytics; you send us their campaign delivery data. Each week, they receive an independent incrementality report on the campaigns they run with you, at no cost to them, and you receive the results too.
Before you start
You need an API token, issued by Provalytics. Tokens begin with pvpk_ and are shown once, at creation. Contact your Provalytics representative to get one.
Your token identifies you — there is no separate account ID to send. Keep it server-side: never in a browser, a mobile app, or anywhere a third party can read it.
Base URL: https://app.getprova.com
Enroll an advertiser
POST /api/partner/v1/advertisers
Authorization: Bearer pvpk_your_token_here
Content-Type: application/json
{
"external_advertiser_id": "your-internal-id-7741",
"company_name": "Acme Windows, LLC",
"contact_name": "Dana Reyes",
"contact_email": "dana@acmewindows.com",
"campaign_ref": "Q4-CTV-1182",
"webhook_url": "https://your-system.example/hooks/provalytics",
"metadata": { "anything": "you want returned to you" }
}
Fields
| Field | Required | Notes |
|---|---|---|
external_advertiser_id | Yes | Your own identifier for this advertiser. Also the idempotency key — see Retries. Max 200 characters. |
company_name | Yes | The advertiser’s legal entity name. See the note below. Max 300. |
contact_name | No | Who you would address an invite to. Not pre-filled into the form — see why. Max 200. |
contact_email | No | Same. Must look like an email address. Max 320. |
campaign_ref | No | Your campaign identifier. Helps us label the account. Max 200. |
webhook_url | No | Overrides your account default. Must be https. Max 2000. |
metadata | No | Any JSON object. Returned to you unchanged. |
company_name is legally load-bearing
Whatever you send is displayed to the advertiser on the sign-up page and becomes the named counterparty on the agreement they accept:
“I agree to the Provalytics Order Form and Terms and am authorized to accept them on behalf of {company_name}.”
The advertiser cannot edit it. Send the legal entity name, not a brand, trading name, or internal shorthand. If you get it wrong, the advertiser should decline rather than sign — contact us to reissue.
Success
HTTP 201 Created
{
"ok": true,
"invite_id": "3f1c…",
"order_form_no": "SA-000007",
"status": "invited",
"expires_at": "2026-10-28T14:02:11Z",
"external_advertiser_id": "your-internal-id-7741",
"signup_url": "https://app.getprova.com/measure/AbC…"
}
Send signup_url to the advertiser. It expires — expires_at tells you when.
Retries and idempotency
external_advertiser_id is the idempotency key, scoped to you. Retrying with the same value returns the original invite with HTTP 200, not a second one.
A retry does not return signup_url. We store the link’s token hashed and genuinely cannot reproduce it, and issuing a new one would silently invalidate a link the advertiser may already be holding. The response says so:
HTTP 200 OK
{
"ok": true,
"invite_id": "3f1c…",
"order_form_no": "SA-000007",
"status": "invited",
"signup_url": null,
"note": "This advertiser was already invited; the signup URL is shown only
when the invite is first created. Contact Provalytics to reissue it."
}
So: capture signup_url from the 201. If you lose it, we reissue manually. A 500 is always safe to retry — the call is idempotent.
Errors and rate limits
| Status | error | Meaning |
|---|---|---|
400 | invalid_json | Body was not valid JSON |
401 | unauthorized | Missing, malformed or unrecognised token |
403 | publisher_mismatch | You sent a publisher_id that is not yours |
422 | invalid_request | Field problems — see fields |
429 | — | Rate limited. Retry after the Retry-After header |
500 | server_error | Our side. Safe to retry |
Field errors are returned together, not one at a time:
HTTP 422
{
"ok": false,
"error": "invalid_request",
"message": "One or more fields are invalid.",
"fields": {
"company_name": "required",
"contact_email": "not a valid email address"
}
}
Rate limit: 60 requests per hour, per IP. That is set well above any realistic enrollment volume — if you are hitting it, talk to us rather than adding backoff.
Sending us campaign delivery data
This is not part of this API, and there is no endpoint for it. Delivery data — impressions, dates and geography for the campaigns your advertisers run with you — arrives as a single scheduled feed covering all of the advertisers Provalytics is measuring for you at once, not per advertiser and not per call.
Today that is normally a Snowflake share or an S3 drop, ingested nightly. The exact shape is agreed once with your Provalytics contact and then left alone — enrolling an advertiser does not change it. What matters is that the advertiser’s rows are present in the feed, identified the same way each time, since that is the only place their delivery data comes from. For most partners they already are, because the feed covers the whole book of business and we filter it per advertiser.
The feed must identify advertisers by external_advertiser_id
Whatever value you send as external_advertiser_id when you enroll an advertiser is the value we filter your feed on. The two have to match exactly — same string, same case, no reformatting between systems.
If they diverge, nothing errors. We simply find no delivery rows for that advertiser and produce a report with no media in it, which reads as a measurement result rather than a broken join. Use the identifier your reporting already emits, not one invented for this API.
Each advertiser authorises this when they accept the Order Form, so no separate permission is needed from them.
What a row has to contain
One row per day, per advertiser, at the finest campaign level you want broken out in their report. You do not need to rename anything: you tell us your column names once and we map them to the roles below.
| Role | Required | Example column | Notes |
|---|---|---|---|
| advertiser id | Yes | demand_partner_id | Must equal the external_advertiser_id you enrolled them with. This is the join. |
| advertiser name | Recommended | demand_partner | Human-readable label, used in our internal tooling. |
date | Yes | report_date | One row per day. Daily grain is what the model fits on. |
impressions | Yes | impressions | Delivered impressions for that day. |
clicks | Recommended | clicks | Send 0 rather than omitting the column if you do not track clicks. See below. |
cost | Yes | spend_cost | What the advertiser was charged, in a single consistent currency. |
conversion | Optional | — | Only if you measure conversions yourself. We do not require it. |
conversion_value | Optional | — | As above. |
Channel hierarchy
The report breaks a campaign down through up to seven levels. Level 1 is your name and we set it — it is how an advertiser sees your inventory separated from every other channel they run. You supply the levels beneath it, in whatever depth your reporting naturally has:
| Level | Source | Example |
|---|---|---|
channel_level_1 | Set by us — your publisher name | AdGood |
channel_level_2 | Yours | supply_tag_name |
channel_level_3 | Yours | campaign_name |
channel_level_4 | Yours | ad_name |
channel_level_5–7 | Yours, if you have them | — |
Send the levels you actually have. Three is typical and plenty; depth costs nothing but adds no value if the names beneath it are not meaningful to the advertiser.
Two things that quietly damage a model
Renaming a campaign mid-flight. To us a renamed campaign is a new one that started from zero beside an old one that stopped, so the model sees a cliff that never happened. If your names change, keep a stable identifier in a lower level and tell us — we can map the old name forward.
Too fine a grain. A dimension that multiplies rows without adding anything the advertiser would act on — site or placement-level detail, say — slows the pipeline substantially without changing a single number in the report. If in doubt, send the coarser version; we would rather add depth later than strip it.
On clicks
If you do not track clicks, send a clicks column containing 0 rather than leaving the column out. Both work — a missing clicks column is treated as zero downstream — but a real column of zeros is one fewer special case in the mapping, and it means the day you start tracking clicks nothing on our side has to change.
What happens next
| Status | Meaning |
|---|---|
invited | You have called us; the link is live and unused |
signed_up | The advertiser completed the form and accepted the terms |
connected | Their Google Analytics is linked and data is arriving |
The advertiser’s journey: they open the link, enter their name and work email, accept the terms, and are taken straight to our data connector to authorise read-only access to their Google Analytics 4 property. Nothing is installed — no pixels, no tags, no cookies.
If they do not have analytics access themselves, the page tells them to forward the link to whoever does. A forwarded link works normally.
The connection webhook
If you send a webhook_url, we POST to it once, when the advertiser reaches connected:
POST your-webhook-url
Content-Type: application/json
X-Provalytics-Timestamp: 1790000000
X-Provalytics-Signature: 9f2a…
{
"event": "advertiser.connected",
"external_advertiser_id": "your-internal-id-7741",
"company_name": "Acme Windows, LLC",
"order_form_no": "SA-000007",
"status": "connected",
"connected_at": "2026-09-28T19:00:00+00:00",
"invite_id": "3f1c…"
}
It leads with your identifier, so you can act on it without storing ours. It carries no analytics data: we may share measurement results with you, never the advertiser’s underlying Google Analytics.
Verifying the signature
The signature is HMAC-SHA256 of "<timestamp>.<raw body>", hex-encoded, using the shared secret we give you. Sign the timestamp and the body together and compare in constant time:
expected = hmac_sha256(secret, timestamp + "." + raw_body).hexdigest()
if not constant_time_equals(expected, header): reject
The timestamp is inside the signed material rather than beside it, so a captured body cannot be replayed under a new timestamp — changing the header invalidates the signature. Reject anything older than a few minutes.
Retries
Any 2xx is success. Anything else — or a timeout, at ten seconds — is retried roughly once a minute, up to fifteen attempts, then we stop and record the last error. Return 2xx as soon as you have the payload and do your own work afterwards; we are not waiting on it.
You may receive a delivery more than once if your 2xx is lost in transit. Treat invite_id as an idempotency key.
Why we do not pre-fill the form
Even when you send contact_name and contact_email, the form starts empty. The person who ends up signing is often not the person you have on file, and a pre-filled name sitting above a legal acceptance checkbox is the wrong default. Use those fields to address your own outreach.
What the advertiser gets
- A weekly incrementality report by email
- Independent third-party modelling — you have no ability to direct, review in advance, or alter the methodology or the results
- No cost to them
We may share the results of the measurement with you. We do not share their underlying Google Analytics data.
Full terms: Order Form — Sponsored Advertiser.
Support: help@provalytics.com. When reporting a problem, include the invite_id or order_form_no — never your API token.
