Guide
Portal integration
Connect your CRM or client portal to AgencyAccess, from creating the client to knowing every service is granted.
This guide walks through a complete integration: your system creates the client and an access request, the client grants access (by email, by link or inside your portal), and your system learns about each grant as it happens. It uses the V2 API throughout.
How the pieces fit
- Your backend finds or creates the client, keyed by your own ID.
- It creates an access request for the services you need.
- The client opens the link: from an email, a link in your app, or an iframe in your portal.
- The client signs in to each platform and grants access to your agency’s accounts.
- AgencyAccess sends webhooks as services are granted and when the request completes; your backend can also read the request at any time.
Everything that needs your API key happens on your server. Browsers only ever see the client’s link.
1. Find or create the client
Store your own ID for each customer on the AgencyAccess client as externalClientId. Then you never need to store our IDs: look the client up by yours, and create it if it doesn’t exist.
const API = "https://api.agencyaccess.co/api/v2"
const headers = { Authorization: `Bearer ${process.env.AGENCYACCESS_API_KEY}` }
async function findOrCreateClient(customer) {
const found = await fetch(`${API}/clients?externalClientId=${encodeURIComponent(customer.id)}`, { headers })
const { data } = await found.json()
if (data.length) return data[0]
const created = await fetch(`${API}/clients`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": `client-${customer.id}` },
body: JSON.stringify({
externalClientId: customer.id,
name: customer.contactName,
email: customer.contactEmail,
company: customer.companyName,
}),
})
const body = await created.json()
if (body.error?.code === "EXTERNAL_CLIENT_ID_CONFLICT") return findOrCreateClient(customer) // created by a concurrent call
if (!created.ok) throw new Error(`${body.error.code}: ${body.error.message}`)
return body.data
}
externalClientId is unique within your agency and can’t be changed once set, so the same customer can’t be created twice. Two calls racing with the same Idempotency-Key get one client: the second waits for the first and receives its result (or 409 IDEMPOTENCY_IN_PROGRESS if the first takes more than about 10 seconds; retry shortly). Keys are per API key. Calls with different keys get 409 EXTERNAL_CLIENT_ID_CONFLICT, which the sample handles by looking the client up again. Emails are not unique, so look clients up by your own ID rather than by email.
2. Choose what to request
A request asks for services (such as Google Ads or Meta Ads), each with a role and your connected accounts that the client grants access to.
GET /serviceslists every service with its roles. Use the serviceidas the key and a rolevalueasaccessLevel.GET /accountslists your connected accounts. Use an account’sinternalAccountIdinrequestedAccountLinks; it must be one of your accounts on the service’s platform. Google Ads MCC links also needgoogleAdsMccAccountId.
Both change rarely; cache them and refresh daily. Mark services the client may skip with optional: true. A request completes once every service is granted, or skipped if it is optional (skipping every service doesn’t complete it).
3. Create the request
// Fix the deadline once and store it with the deal: a retry must send exactly the same body
const expiresAt = deal.accessDeadline ?? new Date(Date.now() + 14 * 24 * 3600 * 1000).toISOString()
const res = await fetch(`${API}/requests`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": `onboarding-${deal.id}` },
body: JSON.stringify({
clientId: client.id,
requestedServices: {
"Google Ads": { accessLevel: "ADMIN", requestedAccountLinks: [{ internalAccountId: "Xk7pQ2mR9vT4wB8nC3dF6hJ1" }] },
"Google Analytics": { accessLevel: "predefinedRoles/editor", optional: true, requestedAccountLinks: [{ internalAccountId: "Xk7pQ2mR9vT4wB8nC3dF6hJ1" }] },
},
intakeForm: { requested: true },
redirectUrl: "https://portal.example.com/onboarding/done",
expiresAt,
}),
})
const { data: request } = await res.json()
// request.inviteUrl: the link for the client; request.embedUrl: the same, for an iframe
- No email is sent unless you set
sendEmail: true, so you control how the client hears about it. expiresAtstops new grants after a deadline; the request then reportsexpired.intakeForm: { requested: true }needs your intake form to be enabled with questions; otherwise the create returns400.- A retry with the same
Idempotency-Keyand body within 24 hours returns the same request. Since the body must match, compute values likeexpiresAtonce rather than on each attempt. - Each request counts once toward your monthly access link allowance (prospects separately). Check
GET /usageto see where you stand; at an enforced limit, creates return403 PLAN_LIMIT_REACHED, and with overage billing extra client links are billed instead.
4. Give the client the link
- Email it yourself, or let AgencyAccess send it with
sendEmail: true. - Link to it from your app:
inviteUrlopens with your branding, on your agency’s subdomain if you have set one up. - Embed it in your portal with
embedUrl: see Embedding.
The link works for anyone who has it, so show it only to the client it belongs to.
5. Track progress
Create a webhook for the events you care about. Most integrations need:
| Event | Use it to |
|---|---|
service.granted | Start work that needs that access, such as importing campaigns |
service.grant_failed | Show the client’s problem to your team |
request.completed | Move the onboarding forward |
request.cancelled, request.expired | Close the onboarding task |
Every request event includes the whole request, as GET /requests/{id} returns it. Use its stateVersion to ignore events older than what you already have, since events can arrive out of order.
6. Reconcile
Webhooks are retried for 24 hours, but your endpoint could be down for longer. Once a day, re-read the requests your system still considers open; one that completed, was cancelled or expired while you weren’t listening no longer appears as open, so look each one up:
for (const id of await openRequestIds()) { // from your own database
const res = await fetch(`${API}/requests/${id}`, { headers })
if (res.ok) await upsertIfNewer((await res.json()).data) // compare stateVersion
}
Outcomes to handle
- Completed: every service is granted (optional services may be skipped instead, but not all of them) and the intake form, if requested, is submitted. If you later ask for more access in the dashboard, the request goes back to
in_progress. - Skipped services: clients can skip optional services only, and can reopen a skipped service until the request completes. Required services must be granted.
- Waiting on you: some services, such as Google Business Profile, are granted by the client sending you an invite. They stay
pendinguntil your team confirms the invite arrived in the dashboard, and the request staysin_progressuntil then. Google Ads is reportedgrantedonce the invitation is sent; your team still accepts it in Google Ads. - Cancelled: you cancelled it with
POST /requests/{id}/cancel, or in the dashboard. Access already granted stays granted; remove it on the platform if needed. Completed and expired requests can’t be cancelled (409 INVALID_STATE). - Expired: the deadline passed before the request completed.