ProvalyticsDevelopers

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.

Version v1 · Last updated 28 September 2026

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

FieldRequiredNotes
external_advertiser_idYesYour own identifier for this advertiser. Also the idempotency key — see Retries. Max 200 characters.
company_nameYesThe advertiser’s legal entity name. See the note below. Max 300.
contact_nameNoWho you would address an invite to. Not pre-filled into the form — see why. Max 200.
contact_emailNoSame. Must look like an email address. Max 320.
campaign_refNoYour campaign identifier. Helps us label the account. Max 200.
webhook_urlNoOverrides your account default. Must be https. Max 2000.
metadataNoAny 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

StatuserrorMeaning
400invalid_jsonBody was not valid JSON
401unauthorizedMissing, malformed or unrecognised token
403publisher_mismatchYou sent a publisher_id that is not yours
422invalid_requestField problems — see fields
429—Rate limited. Retry after the Retry-After header
500server_errorOur 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.

RoleRequiredExample columnNotes
advertiser idYesdemand_partner_idMust equal the external_advertiser_id you enrolled them with. This is the join.
advertiser nameRecommendeddemand_partnerHuman-readable label, used in our internal tooling.
dateYesreport_dateOne row per day. Daily grain is what the model fits on.
impressionsYesimpressionsDelivered impressions for that day.
clicksRecommendedclicksSend 0 rather than omitting the column if you do not track clicks. See below.
costYesspend_costWhat the advertiser was charged, in a single consistent currency.
conversionOptional—Only if you measure conversions yourself. We do not require it.
conversion_valueOptional—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:

LevelSourceExample
channel_level_1Set by us — your publisher nameAdGood
channel_level_2Yourssupply_tag_name
channel_level_3Yourscampaign_name
channel_level_4Yoursad_name
channel_level_5–7Yours, 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

StatusMeaning
invitedYou have called us; the link is live and unused
signed_upThe advertiser completed the form and accepted the terms
connectedTheir 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

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.