Skip to content

Public API Guide

This guide walks you through getting API credentials, authenticating, and making your first calls against the PartnerPortal REST API.

For the high-level overview of your integration options, see Developer Documentation. For real-time event notifications, see the Webhooks Guide.

The REST API is lead- and partner-centric. With it you can:

  • Sync leads both ways by creating, updating, fetching, and listing leads
  • Discover your schema so your integration maps the right standard fields, custom fields, and picklist values
  • Add partners and partner users by creating a partner organization with its owner, then adding further users to it
  • Look up partners by domain or owner email, or list them all
  • List the people at a partner, owners first, so you can sync them to your CRM as contacts
  • Retrieve a partner’s terms acceptance history, including the exact terms text they agreed to
  • Build out your content library by creating, updating and deleting announcements, resources, categories and courses, with uploaded images and attachments
  • Add quiz lessons to courses, with the questions, pass mark and attempt limit set in the same call
  • Receive real-time events via webhooks such as lead/create, lead/update, and lead/delete
  • Automate without code via Zapier

Not available via the API today:

  • partner-facing reads of the library and quiz taking (the content endpoints are for your team and API keys)
  • broad bulk operations with a self-serve key

Bulk endpoints require a user-delegated token, not the self-serve keys you generate in the portal. Everything else in this guide works with a self-serve key, including creating partners and partner users one at a time. If you need server-to-server bulk imports, contact [email protected].

A quick terms note: a lead is a deal or opportunity referred by a partner, a partner organization is a partner company in your portal, and an agent is the partner user credited for a lead.

If your plan includes API access, you can create and manage your own API keys directly in your portal.

  1. In your portal, go to Portal Setup → Other → Developer / API Keys.
  2. Click Create API Key, give it a recognizable name, and confirm.
  3. Copy the Client ID and Client Secret that are shown.

The Client Secret is shown only once. Copy it immediately and store it somewhere secure. If you lose it, revoke the key and create a new one.

You will also need:

  • Audience: https://api.partnerportal.io for production
  • Company ID: the 24-character identifier for your portal

From the same Developer / API Keys page, click Revoke next to any key.

Revocation takes effect promptly, although an already-issued token may continue working until it expires.

Do not see the Developer / API Keys section? API access may not be included on your current plan, or you may not have admin permissions. Contact [email protected].

The keys you generate are machine-to-machine credentials. They authenticate your server, not an individual user, and are the recommended way to build a backend integration.

The same credentials work for both the REST API and Webhooks.

The API uses OAuth 2.0. Before calling any endpoint, exchange your Client ID and Client Secret for a short-lived access token.

Terminal window
curl -X POST "https://login.partnerportal.io/oauth/token" \
-H "content-type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"audience": "https://api.partnerportal.io"
}'

You will get back JSON containing an access_token.

Cache that token and reuse it until it is close to expiry instead of requesting a new one for every call.

Include it in the Authorization header of every request:

Authorization: Bearer YOUR_ACCESS_TOKEN

The simplest way to confirm everything is wired up is to fetch your lead schema.

Terminal window
curl "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/schema/lead" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

A JSON response with a unifiedFields array means you are set up correctly. Each entry tells you the field’s fieldId, label, type, whether it is required, and whether it is creatable or updateable.

If you get a 401 or 403, check that:

  • your access-token request returned 200
  • you are using the access token, not the Client Secret, as the Bearer value
  • you included the header as Authorization: Bearer ...
  • you used the right audience for your environment
  • the key has not been revoked

All REST endpoints live under:

https://api.partnerportal.io/1.0/api

Non-production environments use:

https://api.partnerportal.dev/1.0/api
Terminal window
curl -X POST "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/leads" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Jane",
"lastName": "Smith",
"platform": "Enterprise",
"email": "[email protected]",
"partnerOrganizationId": "PARTNER_ORG_ID",
"agentId": "AGENT_ID"
}'

A successful create returns the created lead, including its new id. Store that id, since that is how you will match incoming webhooks and fetch the lead later.

{
"id": "66a1f2c9e4b0a1234567890a",
"companyId": "YOUR_COMPANY_ID",
"firstName": "Jane",
"lastName": "Smith",
"platform": "Enterprise",
"email": "[email protected]",
"partnerOrganizationId": "PARTNER_ORG_ID",
"agentId": "AGENT_ID",
"status": "pending",
"value": 50000,
"createdAt": "2026-06-29T19:00:00.000Z",
"updatedAt": "2026-06-29T19:00:00.000Z"
}

Required fields vary by portal, so always call GET /schema/lead first and treat each field with required: true as mandatory.

If you are using a self-serve key, partnerOrganizationId and agentId are also required on create, and the agentId must belong to the given partnerOrganizationId.

Picklist values must come from your schema rather than from an arbitrary string.

The calls above reference a few IDs:

Custom fields go under a customFields object keyed by the fieldId from your schema:

{
"firstName": "Jane",
"lastName": "Smith",
"platform": "Enterprise",
"customFields": {
"<fieldId>": "value"
}
}
Terminal window
curl "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/leads/LEAD_ID" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

This is the standard follow-up after receiving a webhook.

Terminal window
curl -X PATCH "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/leads/LEAD_ID" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "value": 50000 }'
Terminal window
curl "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/leads" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Use this to pull historical data or reconcile your copy against the portal.

A partner is a partner organization plus its owner login. Creating one works exactly like inviting a partner from the portal: the partner is activated immediately and the owner is emailed a set-password invite.

Terminal window
curl -X POST "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/partners" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"companyName": "Acme, Inc.",
"domain": "acme.com",
"firstName": "Jane",
"lastName": "Smith",
"email": "[email protected]",
"phone": "+15550100100"
}'

Every field except phone is required, and domain must be unique within your portal. The response is the created partner, including its id. That id is the partnerOrganizationId you will use everywhere else.

Use this to add more users to a partner that already exists, whether you created it with the call above or it was already in your portal. The new user is activated immediately and emailed a set-password welcome, the same as adding a partner user in the portal.

Terminal window
curl -X POST "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/partners/PARTNER_ORG_ID/users" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Sam",
"lastName": "Lee",
"email": "[email protected]",
"phone": "+15550100101"
}'

firstName, lastName, and email are required; phone is optional. The response is the created user, including its id, which is an agentId you can use when creating leads.

{
"id": "66a1f2c9e4b0a1234567890b",
"firstName": "Sam",
"lastName": "Lee",
"email": "[email protected]",
"phone": "+15550100101",
"type": "agent",
"status": "active",
"partnerOrganizationId": "PARTNER_ORG_ID",
"createdAt": "2026-09-07T19:00:00.000Z",
"updatedAt": "2026-09-07T19:00:00.000Z"
}

A few rules apply:

  • the partner must belong to your portal (otherwise you get a 404) and be active
  • the email must not already be in use by anyone in your portal
  • your plan’s per-partner user limit still applies

Every call sends a real email. Both Create a partner and Add a user to a partner email the person a set-password link, exactly as the portal does. Use test data while you are building, and tell partners to expect the email before you run a migration.

To avoid creating a duplicate, look a partner up by domain or by its owner’s email before creating it. Either filter turns the partner list into a single-match lookup.

Terminal window
curl "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/partners?domain=acme.com" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

You get back a list with the one matching partner, or an empty list if nothing matches. If more than one partner matches, the call returns an error instead of guessing, so use a value that identifies exactly one partner. Without a filter, the same call lists every partner in your portal.

A partner record also carries its terms status: termsAcceptedAt is when the partner last accepted your program terms, and acceptedTermsVersion (or acceptedGroupTermsVersion, for a partner group’s own terms) is the version they accepted. Acceptance covers the whole partner, so these live on the partner rather than on each user.

A partner can have more than one owner, plus users added under it. This call lists them all, which is how you get the people behind a partner — for example, to create or update them as contacts in your CRM.

Terminal window
curl "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/partners/PARTNER_ORG_ID/users" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Each user has the same fields as the Add a user response above. type is partner for an owner and agent for a user added under the partner. The list is ordered active users first, then owners, then oldest first. So the first item is the partner’s longest-standing active owner, or, if no owner is active, its longest-standing active user.

Three optional filters narrow the list:

  • type=partner or type=agent returns only owners or only added users
  • status=active returns only active users (other values: pending, declined, suspended)
  • [email protected] returns the user with that email

If the partner does not belong to your portal, you get a 404. If you call this with a partner’s own login rather than a key, it only works for that login’s own partner, and only if that login can see the partner’s users in the portal.

Get a partner’s terms acceptance history

Section titled “Get a partner’s terms acceptance history”

Every time a partner accepts your program terms, PartnerPortal keeps a permanent record of it: who accepted, when, which version, and an exact copy of the terms text they agreed to. Use this to retrieve that history if a partner’s agreement is ever questioned.

Terminal window
curl "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/partners/PARTNER_ORG_ID/termsAcceptances" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

You get back the acceptances newest first. Each one includes acceptedAt, the accepting person (acceptedByFirstName, acceptedByLastName, acceptedByEmail), termsVersion, and scope — company for your program terms or group for a partner group’s own terms, with its groupName. The list leaves out the terms text to stay light. To get the exact text for one acceptance, fetch it by id:

Terminal window
curl "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/partners/PARTNER_ORG_ID/termsAcceptances/ACCEPTANCE_ID" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

The response adds termsSnapshot, the terms HTML exactly as it was when the partner agreed.

A few things to know:

  • Acceptance belongs to the partner. One owner accepts on behalf of the whole partner, so only that person is listed. Other owners and users are covered without an acceptance of their own.
  • The version number changes on every edit to your terms, including small fixes, so it identifies an edit rather than a named document. The saved terms text is the reliable record.
  • History starts when record-keeping began. Acceptances from before PartnerPortal started keeping these records only appear as the partner’s termsAcceptedAt date.
  • The history is for your team only. A partner’s own login cannot read it.

You can see the same history in the portal on the partner’s page, under Terms History.

Announcements, resources and courses can carry files: a featured image, images inside the body, and attachments partners can download. Files are uploaded first and referenced afterwards by id or address. An upload is three steps, because the file goes straight to storage rather than through the API:

1. Ask for an upload slot. Say what the file is for: image for a picture that will be shown (public, served from our CDN, images only, 20 MB), or attachment for a download such as a PDF or video (private, served to partners only through the resource it is attached to, 50 MB). The file name’s extension decides the stored type.

Terminal window
curl -X POST "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/assets/slots" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "fileName": "partner-guide.pdf", "purpose": "attachment" }'

The response holds a slotId, an upload.url, an upload.fields object and an expiresAt time about fifteen minutes out. Treat the slotId as an opaque string; expiresAt is when the upload form stops working. Nothing is stored on our side until you complete the slot.

2. Send the file to the upload address. Post a multipart form to upload.url with every entry of upload.fields as a form field, then the file itself as file, last. With curl, one -F per field:

Terminal window
curl -X POST "UPLOAD_URL" \
-F "key=VALUE_FROM_FIELDS" \
-F "Content-Type=VALUE_FROM_FIELDS" \
-F "Policy=VALUE_FROM_FIELDS" \
-F "X-Amz-Algorithm=VALUE_FROM_FIELDS" \
-F "X-Amz-Credential=VALUE_FROM_FIELDS" \
-F "X-Amz-Date=VALUE_FROM_FIELDS" \
-F "X-Amz-Signature=VALUE_FROM_FIELDS" \
-F "file=@./partner-guide.pdf"

The field names vary, so copy whatever upload.fields contains rather than this list. A 204 means the file landed. The form only accepts the exact name, type and size range the slot was created for.

3. Complete the slot.

Terminal window
curl -X POST "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/assets/slots/SLOT_ID/complete" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

You get back the stored file: its id, fileName, size, contentType, and for images a url. The id is what you pass as thumbnailAssetId or inside attachmentIds; the url is what you put in body HTML as an image source. Completing the same slot twice returns the same file. A 400 saying no file has been uploaded means step 2 has not happened or the form expired: ask for a new slot and upload again. A 404 means the slotId is not one of yours. A file that is uploaded but never referenced stays in storage, so complete only what you intend to use.

Each upload belongs to one item. A featured image or attachment id can be used on one resource or announcement; using it on a second one is refused, with the owning item named in the error. Upload the file again to use it elsewhere. When an item replaces or removes its featured image, or drops an attachment from attachmentIds, that file is deleted. An image used only inside body HTML is referenced by its url and is not tied to an item, so do not use the same upload as both a body image and a featured image or attachment. An attachment id cannot be used as a featured image or the reverse; the error says which kind it is.

Announcement and resource bodies are HTML, the same HTML the portal’s editor produces. The server cleans every body on save and keeps paragraphs, headings, lists, tables, links, images with absolute https sources, basic inline styles, and video embeds (iframes) from YouTube, Vimeo, Loom, Wistia, Canva, iorad and Navattic. Scripts, event handlers and anything else are removed silently, which is why every create and update returns the stored body: compare it with what you sent.

Two editor features are plain links with a marker:

  • a button is <a class="btn btn-primary" href="https://...">Book a call</a> (or btn-outline-primary for the outline style)
  • an in-app link that opens inside the portal is <a href="/leads/register" data-ppio-internal-link="true">Register a lead</a>

A course is a category of type course; its resources are its lessons. Partners see a category only if it is shared with one of their partner groups (groupIds) or with their partner directly (partnerOrganizationIds). You can create it first and assign the audience later, but until you do, no partner can see it and the response says so in warnings.

Terminal window
curl -X POST "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/categories" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Partner Onboarding",
"type": "course",
"welcomeMessage": "<p>Welcome! Work through these lessons in order.</p>",
"groupIds": ["PARTNER_GROUP_ID"]
}'

GET .../categories lists categories and courses (?type=course narrows it), GET .../categories/{id} returns one with its lessons in order for a course, and PATCH .../categories/{id} changes the fields you send. groupIds and partnerOrganizationIds replace the current audience when sent. The type cannot be changed after creation.

A resource is a post: a name, an HTML body, an optional excerpt and featured image, one or more categories, and attachments. Putting a course id in categoryIds makes it a lesson in that course.

Terminal window
curl -X POST "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/resources" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Lesson 1: Your first referral",
"excerpt": "How to register a lead and what happens next.",
"body": "<p>Start here.</p><img src=\"https://cdn.partnerportal.io/public/IMAGE_FROM_UPLOAD.png\"><p>...</p>",
"categoryIds": ["COURSE_ID"],
"thumbnailAssetId": "IMAGE_ASSET_ID",
"attachmentIds": ["ATTACHMENT_ASSET_ID"]
}'

GET .../resources lists resources (?categoryId= narrows to one category or course, ?search= matches names), GET .../resources/{id} returns one, and PATCH .../resources/{id} changes the fields you send. categoryIds and attachmentIds are complete lists that replace the current ones; an empty attachmentIds removes every attachment, and categoryIds may not be empty. Send "thumbnailAssetId": null to remove the featured image. A removed featured image or attachment is deleted, so upload a fresh file if you want it back. The legacy file, video-embed and link resource types cannot be created here; they are read back as they are.

To put lessons in order, send every lesson id in the wanted order:

Terminal window
curl -X PUT "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/categories/COURSE_ID/lessons/order" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "resourceIds": ["LESSON_1_ID", "LESSON_2_ID", "LESSON_3_ID"] }'

The list must name every lesson in the course, each once.

A quiz lesson is a resource of type quiz in a course. Partners complete it by passing it, not by marking it done, and the course’s completion and any certification gated on it follow from that. Send the questions with the lesson; only question text and choice text are required.

Terminal window
curl -X POST "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/resources" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Onboarding basics check",
"type": "quiz",
"categoryIds": ["COURSE_ID"],
"quiz": {
"passingPercent": 80,
"questions": [
{ "text": "Which plan includes SSO?", "choices": [
{ "text": "Starter" }, { "text": "Essentials", "isCorrect": true }, { "text": "Free" } ] },
{ "text": "Which of these are payout methods?", "choices": [
{ "text": "PayPal", "isCorrect": true }, { "text": "Stripe", "isCorrect": true }, { "text": "Cash" } ] }
]
}
}'

What is filled in for you: question and choice ids, their order (the order you sent), a question’s type (single when one choice is correct, multiple when more than one is, in which case the partner must pick all of them), a pass mark of 70 percent and unlimited attempts. Set passingPercent (1 to 100) and maxAttempts (a whole number, or null for unlimited) to change them. A definition that is not valid is refused with the first problem as the message and the full list in the details, and nothing is created. Every category id must be a course.

GET .../resources/{id}/quiz returns the stored definition, answer key included, and PUT .../resources/{id}/quiz replaces it:

Terminal window
curl -X PUT "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/resources/QUIZ_LESSON_ID/quiz" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "passingPercent": 90 }'

questions replaces every question when sent (send each question’s id back to keep it); leave it out to change only the pass mark or attempt limit. The body takes only questions, passingPercent and maxAttempts: when you edit a GET result and send it back, drop its resourceId and updatedAt (the id and order fields inside questions and choices may stay). Partners’ past attempts keep what they were shown, so editing never changes a recorded score. A lesson’s type cannot be changed afterwards: to turn a post into a quiz, delete it and create a quiz lesson. The quiz routes are for your team and API keys; a partner takes the quiz in the portal and never sees which choices are correct.

Terminal window
curl -X POST "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/announcements" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "New partner guide available",
"body": "<p>The 2027 guide is live. <a class=\"btn btn-primary\" href=\"/resources\" data-ppio-internal-link=\"true\">Open resources</a></p>",
"excerpt": "The 2027 partner guide is now in your resource library.",
"thumbnailAssetId": "IMAGE_ASSET_ID"
}'

A new announcement is a draft unless you say otherwise. Publishing makes it live for partners and cannot be undone, so it has to be asked for explicitly with "status": "published", on create or on a later PATCH. Emails are opt-in, as in the portal’s own editor: set "sendNotifications": true to email the audience when it publishes (the partners you chose, plus your portal team when visibleToTeam is true); left out, it publishes quietly. The response tells you what happened: published is true when this call made it live, and notificationsSent is true when emails went out. The audience has two parts. visibility says which partners see it: all (the default when omitted), select-partner-groups with selectedPartnerGroupIds, select-partners with selectedPartnerOrganizationIds, or none. visibleToTeam says whether your own admins and staff are emailed and notified about it; it is true when omitted. An announcement cannot be published with an empty audience: a group or partner restriction must name at least one, and none must include the team.

GET .../announcements lists announcements newest first, drafts included (?status=draft or ?status=published narrows it), GET .../announcements/{id} returns one, and PATCH .../announcements/{id} changes the fields you send.

Terminal window
curl -X DELETE "https://api.partnerportal.io/1.0/api/company/YOUR_COMPANY_ID/resources/RESOURCE_ID" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

DELETE .../resources/{id}, DELETE .../announcements/{id} and DELETE .../categories/{id} each answer { "id": "...", "deleted": true }. Deletes are final. Nothing is kept for restoring, so read the item first if you may need it again.

  • Deleting a resource also deletes its featured image and attachments and, for a quiz lesson, its questions and every partner’s attempts. It leaves every category and course it was in.
  • Deleting an announcement also deletes its featured image and the in-app notifications about it. Emails already sent are not recalled.
  • Deleting a category or course keeps the resources in it. It is refused while any of them is in no other category, so move those resources first (PATCH with new categoryIds) or delete them.

The content endpoints are for your team and API keys. A partner’s own login cannot call them.

  1. Subscribe to the lead/create webhook topic.
  2. When a webhook arrives, verify the signature and read the lead id.
  3. Call GET /company/YOUR_COMPANY_ID/leads/{id} to fetch the full record.
  4. Save it in your system, using topic + id + timestamp as a deduplication key.
  1. Pull your schema once at setup with GET /schema/lead.
  2. For each lead, POST /company/YOUR_COMPANY_ID/leads with the mapped body.

Webhooks are forward-looking. To pull historical data or catch anything missed during an outage, periodically list leads and diff against your local copy.

Moving your partner list out of a spreadsheet, a CRM, or another partner platform is a loop over two calls with a self-serve key:

  1. For each partner, POST /company/YOUR_COMPANY_ID/partners with the company and owner details, and store the returned id.
  2. For each additional user on that partner, POST /company/YOUR_COMPANY_ID/partners/{id}/users.

Every call emails one person a set-password link, so run the migration in sequence rather than all at once, and let your partners know the email is coming.

To keep the people behind your partners in your CRM as contacts:

  1. Periodically list your partners, or use the Zapier Partner Updated trigger to react to changes as they happen.
  2. For each new or changed partner, call GET /company/YOUR_COMPANY_ID/partners/{id}/users?status=active and upsert each user, or just the first one (the longest-standing active owner), as a contact.
  3. Copy termsAcceptedAt and acceptedTermsVersion from the partner record onto the contact if you track terms in your CRM.

Build out your content library from files and scripts

Section titled “Build out your content library from files and scripts”

Moving a library of guides, decks and videos into your portal, or generating courses from content you already have, is a loop over the upload and content calls:

  1. Create the course or category once with POST /company/YOUR_COMPANY_ID/categories, and keep its id.
  2. For each file, run the three upload steps and keep the returned id (and url for images).
  3. For each resource, POST /company/YOUR_COMPANY_ID/resources with the body HTML, the course id in categoryIds, the image id as thumbnailAssetId and the file ids in attachmentIds.
  4. Finish with PUT .../categories/{id}/lessons/order to fix the lesson order, then PATCH the course with its groupIds to make it visible.

Keep announcements for last and leave them as drafts until someone has read them in the portal; publishing with sendNotifications on emails your partners.

If you work with an AI assistant, the same operations are available as tools through the PartnerPortal MCP server, including an upload from a public URL.

  • cache your access token and refresh it only as it nears expiry
  • be reasonable with request volume and back off if you receive a 429
  • verify webhook signatures instead of trusting source IPs
  • rotate keys you no longer use and revoke immediately if a secret may have leaked

If you run into auth errors, unexpected responses, or design questions, contact [email protected].