docs
/
Courses — AppEngine

AppEngine Data APIs: One Pattern for Every Module

Watch on YouTube ↗

Video chapters
  1. AppEngine Data APIs: One Pattern for Every Module
  2. Prepare the companion and accounts
  3. Connect the example client
  4. Find the current contract
  5. Inspect the actual campaign schema
  6. One record keeps its identity
  7. One organization, separate authenticated people
  8. Create the campaign draft
  9. Adapt the repository query
  10. Find and reopen the campaign
  11. Change the description; retain the rest
  12. Save one precise change
  13. Restore a disposable draft
  14. Launch performs the campaign operation
  15. Launch, pause and resume
  16. A community post carries membership and authorship
  17. Create the community page
  18. Join and publish as a customer
  19. Read the feed pagination contract
  20. Page through the member feed
  21. Save a comment and verify its count
  22. A report moves from the member to staff review
  23. Submit a customer report
  24. Record the staff review
  25. Find the next module in the reference
  26. The same pattern, two verified outcomes

Written guide

Illustrated course manuscript · AppEngine · 16 October 2026 · Video ready for review

This course applies AppEngine's shared record contract to real module behavior. It builds a campaign workspace for staff and a community interface for signed-in customers. The record pattern transfers between modules; permissions, field definitions and business actions still come from each module's current reference. Recorded demonstrations now cover campaign creation, finding and editing records, launch/pause/resume, Trash recovery, customer membership, posts, comments, feed pagination, reports and staff review. Independent readback confirmed the actual recorded campaign, post, comment count and report decision. The 7:54 video has passed independent encoded audio/visual checks and all 26 chapter-link checks. Watch the course video.

Before you start

Open Appmint Studio for your development organization and the maintained AppEngine reference. Use your organization's assigned backend address, organization ID and appropriately permitted staff account. The Studio website address is not automatically the API origin.

API Foundations shows organization setup at 0:50, customer signup at 1:51, customer sign-in and challenge handling at 2:26, and staff versus customer identity at 7:11. Complete returned verification or temporary-password challenges before continuing. Obtain a customer session for the same organization; a staff token does not become a customer token because both accounts use the same email. Existing Database and Repository APIs covers the full generic record operations. This lesson uses them briefly and concentrates on module-specific decisions. API Reference covers deployment at 0:54, caller at 1:25, and endpoint lookup at 2:35.

The companion browser example has separate staff Campaigns, member Community and staff Moderation views. The recording uses legitimately authenticated, controlled sessions held in browser memory without exposing their credentials. The linked companion implementation keeps deployment configuration and tokens out of source control. An actual deployed client must apply its authentication and session lifecycle, not trust an editable role label in the browser.

1. What carries over—and what the module adds

Why this matters

AppEngine gives your application a common way to work with records across its modules. That becomes useful when you know what each record means, who may use it, and which actions the platform should perform for you. We'll build two small interfaces: a campaign workspace for staff, and a community feed for customers. You'll see the same record structure underneath both, and the different rules that make each feature work.

The two examples keep their saved identities throughout: the campaign moves from draft to active with a real start time, and a member’s post gains a comment before a related report reaches staff review. Each operation is checked against its saved result.

A physical campaign and its saved AppEngine record identity
Concept illustration: a campaign has a saved identity and business fields under data. Reopen the saved ID after finding it by name; the billboard itself is not an external ad delivery result.

2. Find the current contract before designing the form

Open the maintained API reference and search campaign-manager. Open create, get and launch. Keep the current request body and allowed outcome visible while choosing the actual form fields. Open Repository collections for campaign to inspect the organization’s schema. A menu label such as Marketing is not the datatype identifier.

Why this matters

I start with the feature's action routes, then its record schema. Campaign Manager tells us how a draft is created and what launching means. The collection schema tells us which stored fields belong to a campaign. The returned record has its own identity and datatype; business values sit inside data. Keep that identity when you reopen the campaign. A display name is useful for searching, but it isn't a substitute for the saved record ID.

The generic read contract is POST /repository/find/campaign with {query, options}. A field filter uses paths such as data.name. The get route reads by saved ID. The module's get returns its richer campaign view, including permitted actions, blocked actions and metrics. Do not treat those response shapes as interchangeable.

Reference lookup also supports /documentation/search?q=campaign-manager and the individual endpoint reference. These machine-readable routes help tooling locate the live contract; they are not a second request language to learn.

Maintained API reference showing Campaign Manager launch
Actual reference: search campaign-manager, then inspect the module action, access requirements and possible outcomes before building the form.
Live campaign collection schema returned to the example client
Actual collection schema: name, description, type, status and budget come from the campaign definition. This inspection uses existing training records; the new draft is created in the next demonstration.

3. Choose the actor, then connect

Why this matters

The organization header chooses the business. The session identifies the person making the request. Our campaign workspace uses the signed-in staff member. The community uses its signed-in customer. Moderation returns to staff. Keep these sessions separate and ask the reference which actor an operation accepts. Exposing a staff token in a public customer application would give the wrong authority to that application.

Confirm the client’s authenticated identity and organization context before working. The authentication prerequisite explains how to obtain the appropriate session. Selecting a different browser view does not grant permission; the server checks the actual session for each operation.

Companion account connection form with organization and account type
Actual companion connection form. Enter your organization and choose the account type required for the view. Customer is selected here to illustrate the separate identity; it does not grant campaign or moderation authority. Credentials are intentionally blank.

4. Create a campaign through Campaign Manager

Use a controlled billboard campaign named Neighbourhood workshop with a modest illustrative budget and description. Its channel is an offline physical campaign. POST /crm/marketing/campaign-manager/create validates editable fields and creates a draft. Show the resulting draft, budget and available actions, then reopen it by returned ID.

Why this matters

Our form creates a billboard campaign through Campaign Manager. The platform supplies the draft state and tells the client which actions are available. This is a physical campaign, so activating it records the business campaign as running. It doesn't purchase an advertisement or send a message. Email and connected advertising channels have their own provider prerequisites and delivery results.

Do not invent impressions or conversions. If no measurement has been recorded, show the actual untracked or empty state. Provider integration and full Marketing references cover connected channels; this course does not claim to execute those channels.

New campaign draft with budget and available launch action
Actual saved result: Neighbourhood workshop is a Draft with a $300 budget. Physical and Not tracked describe this billboard campaign. No advertisement was bought or sent.

5. Reuse the data pattern for useful reads

Create two additional harmless campaign drafts so pagination has an observable result. The workspace filters only its course campaign names, uses a stable sort, and requests one or two rows per page. Demonstrate next page, first page and a precise name filter. This filter is not full-text keyword search; Repository 6:52 demonstrates the separate keyword search route. Open the intended record by its returned ID. A page past the last result is empty; returning to page one recovers it without creating another record.

Why this matters

Now the same repository query can power a focused list. The filter is on campaign data; page and page size control how much comes back. When a filter changes, reset the page so an old page number doesn't hide the results. Opening a card uses the saved ID. For business-aware controls, read Campaign Manager's view: it adds the actions and tracking information that the plain record list doesn't supply.

A customer feed is not automatically a staff repository query with a different screen design. Use its module read route where viewer-specific access, membership or enrichment is part of the contract.

Filtered campaign list with the intended saved campaign open
Actual name-filtered result. The client opens the returned record ID, then reads Campaign Manager for the business view. Name filtering is separate from keyword search.
Maintained Repository find contract showing query and options
Actual reference for the generic find request. Its sample email filter illustrates the body structure. This course’s campaign list substitutes data.name and its own paging values; it does not query campaign records by email.

6. Make a precise change and verify the saved record

Use the training campaign for the precise edit, and a separate disposable draft for Trash recovery. Read the training campaign, change only description through POST /repository/update-partial/campaign/:id, and read it again. Its body is a flat update map such as {"data.description":"Includes the printed workshop schedule"}. Show that name and budget remain intact.

Why this matters

A partial update names exactly what should change. Here only the description changes; the budget and name remain. A full update is a different contract: work from the current complete record and preserve the fields you intend to retain. Repository 8:07 demonstrates that complete-record update and conflict contract. For a module-controlled edit, prefer the module's update route. That route can validate editable fields or synchronize a connected platform.

Account, customer and credential records have dedicated user APIs; the existence of a repository datatype does not make generic writes appropriate. Never demonstrate a production record being overwritten for this lesson.

Repository 9:21 is the full Trash lesson. Here, delete only the disposable draft through the supported repository delete route. Find its actual Trash entry, restore that entry through /repository/trash-restore using a JSON array of original record IDs, then reopen the original record. Readback must establish the restored identity and retained values. The live rehearsal verified Trash contains the original ID, restoring [recordId] returns that identity, and its budget is retained.

Saved campaign description with original budget retained
Actual readback after changing only data.description. Includes the printed workshop schedule is saved, while the campaign name and $300 budget remain.
Separate spare campaign restored from Trash
Actual recovery of Workshop spare draft with its original $75 budget. This separate disposable record is used for deletion and restoration; the main campaign is retained.

7. Launch is a business operation

Why this matters

A status field describes a campaign. Launching is the platform's business operation. Campaign Manager checks the current state, handles a pending schedule, and records the start time and actor. Its returned actions change with the state. Setting a field to active yourself would skip that sequence.

Use the client's Launch control to call the actual module action. Show the resulting active campaign, started time and changed next actions. Reload it, pause and resume through their routes. The recorded result remains active after Resume; Complete is another state-dependent action, not the final action shown here.

For this billboard branch, no email or advertising provider is called. For email, launch requires a linked broadcast and queues that broadcast; for ads, it uses connected platform execution and records platform results. A local state update must never be presented as external delivery.

Active campaign after launch, pause and resume
Actual final recorded state: Active after Resume. The start time and launching staff member are retained, and available actions reflect the current state. This is not a completed campaign or external delivery proof.

8. Apply the pattern to a customer community

Open the Community reference. Create a controlled page with explicit active status, public visibility, open join policy and members posting policy. The page owner is a controlled customer. Sign in as a second controlled customer and join before posting. If posting before membership fails, show the policy message, join, then repeat the same intended post.

Why this matters

The community uses familiar records, but creating a post has different responsibilities. The page's policy decides who may post. The signed-in customer supplies the author. AppEngine extracts hashtags and maintains the page's post count. Our application supplies the content and page; it doesn't invent those effects by inserting a raw row.

Show the policy on the real page setup and the member's visible join result. No unexplained customer or page identity appears halfway through the demonstration.

Community page with public visibility, open joining and members posting policy
Actual page created by controlled customer Noah. Its policies are visible together. The owner is already an active member; Maya joins as the second controlled customer.

9. Post, page through the feed and comment

Create the member post “The Saturday workshop schedule is ready. Which session would you join? #workshop” through /client/community/posts. Show it in the actual feed and reopen the same saved post. Use another controlled post so next-page behavior is meaningful. The feed uses page for the community page ID and pageNum for the numeric result page, plus limit. Its result is scoped to the viewer and enriched with that viewer's reaction/bookmark state.

Why this matters

The naming matters here: page selects the community space; page number selects a batch of feed results. The feed also knows who is viewing it. Don't replace it with an unrestricted query and expect the same membership and visibility behavior. Our customer posts, then the application reloads the feed from AppEngine. The saved author, hashtag and post are now visible together.

Add a comment through /client/community/posts/:postId/comments, reload comments and read the parent post's updated comment count. A comment row alone is not the complete outcome; the linked post and its count must agree.

Maya active membership and saved workshop post
Actual result after the first non-member post was refused, Maya joined, and the same intended post was submitted successfully. The saved author, content and hashtag appear together.
Current feed reference distinguishes page from pageNum
Actual maintained reference: page selects the community page ID; pageNum selects the numeric results page. Use limit for the number of results.
Community feed showing saved posts after paging
Actual feed after moving through a later result page and an empty page, then returning. Different saved posts provide meaningful pagination; the empty result is not permission to create a duplicate post.
Noah reply below Maya post with one comment counted
Actual linked outcome: Noah’s reply appears beneath Maya’s post, and the parent post reports one comment. The saved row and count were independently read back.

10. A report crosses from customer to staff

The report API reports a customer, with optional context linking the relevant post. Use the second controlled member as the reported identity; do not report unrelated real people. Submit a training report with reason other and clear harmless details. The response is a community_block report record. Preserve its ID.

Why this matters

A customer can report a concern, but reviewing it belongs to staff. The report identifies the member and can carry the post as context. Our customer interface confirms submission. The staff interface opens that same report and records its decision. Reviewing a report doesn't itself remove a post, so the interface must not claim that content disappeared unless a separate moderation action actually did that.

Verify that a customer token calling the staff review route is refused. The legitimate staff client then opens the report and sends dismissed with a note for this harmless rehearsal. Re-read the saved report: decision, reviewer and reviewed time must match. Never label a denied attempt as a product failure or substitute an elevated token invisibly.

Customer confirmation that the report awaits staff review
Actual customer result: report submitted and waiting for staff. The original post remains visible.
Staff review saved as dismissed with reviewer and time
Actual staff result for that same recorded report: Dismissed, with reviewer and review time. Both list and detail agree. This action did not remove the post.

11. Find the next module without guessing

Open the actual maintained module pages rather than reading a static catalogue. Keep the following reference map in the written companion:

FeatureFind in the referenceConsequential distinction
Messages and templates (message, messagetemplate)CRM Communications, Message Templates, InboxSaved content, queued send and delivered message are different outcomes.
Broadcasts and noticesBroadcast, NoticesAudience and delivery behavior belong to module actions.
Campaigns, social and ads (campaign)CRM Marketing, Social, AdsLaunch/publish needs channel configuration and platform results.
Affiliates and enrichmentAffiliate, Data EnrichmentUse each action's prerequisites and returned result.
Content and community (community_post)Content Studio, Community, CommentsPublication, membership and moderation affect what the customer sees.
LearningContent Studio participant and staff routesEnrollment, answer and review are a sequence, not arbitrary progress edits.
Analytics and administration (usage record; computed reports remain separate)Analytics, Stats, Usage, Monitoring, Organization ManagementReports can compute or aggregate data; privileged administrative actions are not ordinary client writes.

Why this matters

This is how the pattern scales: find the module, identify its stored data, then inspect its business actions and actor requirements. A saved message isn't delivery. A progress value isn't a completed review. A report may be computed rather than another editable table. The reference carries the current fields and routes; your interface should carry the meaning and show the result.

The existing Messaging, Marketing, Content and Community, and Analytics and Administration written references retain detailed module coverage. Their scope is broader than the two demonstrated examples here.

Maintained analytics reference describes calculated period results
Actual reference for lead analytics: calculated results, a period comparison and cache behavior. This is a module result contract, not a schema for an editable record.

12. Diagnose the correct layer

Why this matters

If a result is wrong, check the layer that owns it. Confirm the exact datatype and data field path when a query misses records. Reset pagination when the filter changes. Check the actor for a forbidden operation. Use a precise update for a small edit, and the module's action for a business transition. Then read the saved record and the business view again. You can now take an unfamiliar AppEngine module from its reference into a working client without rebuilding its rules.

The finished example has an active campaign with its saved description and launch details, plus a dismissed community report with its reviewer and time. The original post remains visible. Reopening these results confirms what each operation actually changed.

Run the companion application

The course includes a real browser client and language examples. The browser client’s results are backed by AppEngine; the concept illustrations above are labelled separately.

  1. Prepare your assigned API origin, organization ID and appropriately permitted staff account. Prepare two controlled customer accounts in the same organization for the owner/member exercise. Follow the authentication prerequisite above for challenges and token acquisition.
  2. Open the companion README. From its browser folder, run APPENGINE_API_ORIGIN=https://YOUR_API_ORIGIN node server.mjs using Node 20 or later. Replace the example origin with your assigned backend.
  3. Open the local development address printed by the server. Connect the staff account for Campaign workspace and Moderation; use a separate customer sign-in for Community. Sessions remain in page memory, so a reload requires sign-in again.
  4. Start with harmless development records. The illustrated billboard example records an offline business campaign and does not send advertising or messages. For the report exercise, use your other controlled customer as the reported identity.
  5. Read the exact saved result after each action. A changed button label, a successful HTTP response or a toast is not a substitute for the campaign state, post, comment count or report decision.
ImplementationSource
Browser interface and local proxyInterface JavaScript, proxy server, HTML, styles
JavaScript command-line clientrun.mjs
Python clientrun.py
TypeScript integration methodsclient.ts
JSON request bodies and actor requirementsoperations.json
cURL and PostmancURL runner, Postman collection

Set the language clients’ APPENGINE_API_ORIGIN, APPENGINE_ORG_ID and the required APPENGINE_STAFF_TOKEN or APPENGINE_CUSTOMER_TOKEN in your private environment. For example, node http/run.mjs campaign-find and python3 http/run.py campaign-find run the same named read. Returned IDs supply the next operation’s CAMPAIGN_ID, PAGE_ID, POST_ID or REPORT_ID; a report also needs the actual REPORTED_CUSTOMER_ID. Keep credentials out of source control and public client bundles.

The demonstration membership indicator inspects the first 100 returned members. That is enough for this controlled two-customer exercise. A larger application must paginate membership results or use a supported exact current-member check before treating an absent match as not joined. The local proxy and in-memory sign-in are a runnable learning example, not a production authentication service.

The complete companion source is published on GitHub, including browser, JavaScript, TypeScript, Python, cURL and Postman examples. Publication commit: 48ea958. Clone the examples repository and open appengine-data-apis-pattern; a separate packaged download is not required.

Limitations and checks

Source reviewed: AppEngine repository controller and paging; documentation controller; Marketing controller and CampaignManager service; Community client/staff controllers, page, post and block services; current customer signup/sign-in controller and service. Specific paths and source ranges are recorded in the production plan. The restored development services passed exact saved-record checks for campaign launch/partial edit, community posting/comment/report and staff review, and Trash restoration. Actual client and maintained-reference capture scenes are available and illustrated above. Independent checks of the recorded fixtures confirmed the campaign remains active with the edited description and original budget; the post belongs to the intended community page with its comment count; and the same report is dismissed with reviewer and time. The earlier rehearsal also checked that non-members cannot post before joining and customer sessions cannot perform staff review. Final video assembly, continuous timing review and encoded audio/visual QA remain pending; this written reader is not a claim that the video is ready.

AppEngine Email, Broadcast and Notification APIs — supporting guide

Companion source code

Course examples and setup on GitHub. This module shares its companion with the linked consolidated course.

Running example: Northside Physio talks to its patients. It emails Dana her exercise plan from a template, texts her a one-off reminder, sends the monthly newsletter to its patient list, and shows a "closed for the holiday" notice on its website.

1. Why let AppEngine send your messages

Every business app ends up sending things: confirmations, reminders, replies, newsletters, banners on the website. Doing it well is fiddly. Someone has to fill in the customer's name, refuse to send "Hi ," when the name is missing, pick the right sender, retry when the provider hiccups, record what was sent and whether it arrived, keep unsubscribed people off the newsletter, and stop a banner from nagging a visitor who already closed it.

AppEngine does all of that. You hand it a template and the values, or a message, or a saved broadcast, and it renders, sends through your configured email or SMS service, keeps a record with the delivery status, and updates that record as the provider reports back. Many AppEngine actions already send their own messages — a booking sends its confirmation, a ticket reply goes out by email — using the same pipeline you will use here.

2. What is built in

A few terms first:

  • A record is one saved item. It has an sk (its ID), a datatype (its kind) and a data object with its fields.
  • Your organization ID goes in the orgid header of every request; a bearer token (Authorization: Bearer …) says who is calling. This course uses a staff token.
  • A template is a saved message with placeholders such as {{data.firstName}} that AppEngine fills in when it sends.
  • A provider is the outside service that actually delivers: an email service such as Amazon SES, or Twilio for SMS.

Built-in data types

DatatypeWhat it isFields you will use
messageone email or SMS, sent or receiveddeliveryType (email, sms, …), from, to, subject, text, html, status, statusHistory, direction, context
messagetemplatea reusable message with placeholdersname, subject, html, text, deliveryType
email_broadcasta campaign to a listname, channels, senderAccountIds, recipientList, emailConfig, smsConfig, status, metrics
broadcast_deliveryone recipient's copy of a campaignrecipient, status, messageId, provider, error, sentAt, deliveredAt, openCount, clickCount
site_noticea banner, pop-up or toast on your websitetitle, bodyText, displayType, trigger, showFrequency, audience, actions, status

A message's status moves through new (saved), pending (queued), sent or failed, and then delivered or read when the provider reports it. Each change is added to statusHistory with a statusText explaining it.

AppEngine also ships ready-made templates for its own messages — reservation-confirmation, reservation-rescheduled, customer-welcome, customer-invitation, forgot-password and many more. Your organization can save its own template under the same name to change the wording; AppEngine looks for yours first, then the shipped one.

Built-in messaging actions

You callAppEngine does for you
POST crm/test-templaterenders a template with sample values and lists any placeholder left empty; sends a test only if you ask
POST crm/send-templatefinds the template, refuses to send if any placeholder has no value, then queues the message
POST crm/inbox/update?send=truesaves a one-off email or SMS, queues it, retries on temporary provider errors, records the outcome
GET crm/inbox/messages/:idthe message with its current status and history
GET crm/inbox/conversationsthe inbox grouped into threads per contact and channel
POST broadcast/audience/countcounts who a campaign would reach, leaving out unsubscribed and bounced addresses
POST broadcast/:id/sendchecks the campaign is a draft and the bulk-email agreement is accepted, then queues the run
GET broadcast/:id/report, GET broadcast/:id/statsper-recipient results and totals for one run
GET notice/feedthe notices this visitor should see now, with ones they already dealt with left out
POST notice/:id/display, click, accept, reject, dismissrecords what the visitor did, which decides whether the notice shows again

The rule

  1. Use the built-in action. If you create a message record without send=true, nothing goes out; if you call a provider yourself, the message never reaches the inbox, the customer's history or the delivery reports.
  2. Use the built-in data types. Write your wording as a messagetemplate, your campaign as an email_broadcast, your banner as a site_notice — all the screens and reports read them.
  3. Your own collection only for something messaging has no type for, through the Repository routes in the Data records course.

3. Set up

Follow AppEngine foundations for your address, organization ID and a staff token:

API='https://appengine.appmint.io'
ORG='your-organization-id'
STAFF_TOKEN='…'      # staff: sends messages and broadcasts
CUSTOMER_TOKEN='…'   # a signed-in customer, for the website notice in section 9

For visitors who are not signed in, your website uses its own app token instead of CUSTOMER_TOKEN (see Authentication).

Then give AppEngine something to send through:

Use an inbox and a phone you own while you practise — every send in this course really goes out.

4. Preview a template before anything is sent

Northside wants an "exercise plan ready" email. Try the wording first. test-template renders it and tells you what's missing, without sending:

curl -sS -X POST "$API/crm/test-template" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \
  -H 'Content-Type: application/json' --data '{
    "template": {
      "name": "exercise-plan-ready",
      "deliveryType": "email",
      "subject": "Your exercise plan, {{data.firstName}}",
      "html": "<p>Hi {{data.firstName}}, your plan from {{data.therapist}} is ready.</p>",
      "text": "Hi {{data.firstName}}, your plan from {{data.therapist}} is ready."
    },
    "data": { "firstName": "Dana" }
  }'
{
  "subject": "Your exercise plan, Dana",
  "html": "<p>Hi Dana, your plan from  is ready.</p>",
  "text": "Hi Dana, your plan from  is ready.",
  "sent": false,
  "template": "exercise-plan-ready",
  "unresolved": ["data.therapist"]
}

Placeholders read from data: the value you send as data.firstName fills {{data.firstName}}. Here unresolved shows that therapist is missing — add "therapist": "Sam" and preview again until unresolved is empty. You can also preview against a real record with sampleDatatype and sampleId (for example a customer), or preview a saved template by passing its name as template. When you're happy, save it as a messagetemplate called exercise-plan-ready by following Give your email a stable name and Save and reopen.

5. Send from a template

curl -sS -X POST "$API/crm/send-template" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \
  -H 'Content-Type: application/json' --data '{
    "template": "exercise-plan-ready",
    "to": ["[email protected]"],
    "data": { "firstName": "Dana", "therapist": "Sam" },
    "channel": "email"
  }'
{ "queued": true, "template": "exercise-plan-ready", "to": ["[email protected]"] }

AppEngine found the template, checked every placeholder had a value, and queued the email for the sending pipeline. If a value is missing it refuses with 400:

{
  "message": "Template \"exercise-plan-ready\" was not sent: no value for {{data.therapist}}. …",
  "reason": "UNRESOLVED_PLACEHOLDERS",
  "unresolved": ["data.therapist"],
  "available": ["data.firstName"]
}

Show unresolved to whoever triggered the send. An unknown template name returns 404.

6. Send a one-off message

For a message with your own words — "Running 10 minutes late, sorry!" — skip templates and send a message record:

curl -sS -X POST "$API/crm/inbox/update?send=true" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \
  -H 'Content-Type: application/json' --data '{
    "data": {
      "deliveryType": "sms",
      "from": "+13125550100",
      "to": ["+13125550142"],
      "text": "Hi Dana, Sam is running 10 minutes late today. Sorry! - Northside Physio"
    }
  }'
{
  "sk": "6722b8e0f1a2b3c4d5e6f701",
  "datatype": "message",
  "data": {
    "deliveryType": "sms",
    "from": "+13125550100",
    "to": ["+13125550142"],
    "text": "Hi Dana, Sam is running 10 minutes late today. Sorry! - Northside Physio",
    "status": "pending",
    "tries": 0
  }
}

The message is saved, marked pending and handed to the sending pipeline. For email, use "deliveryType": "email", an email from, and a subject with html or text. A send needs deliveryType and text or html; without them you get 400. Add "context": [{"datatype": "lead", "id": "…"}] and the message also appears on that lead's timeline.

To prepare a message for someone to check first, post it without ?send=true: it is saved with status new and nothing goes out. To send that saved draft later, post the same record — with its sk — to crm/inbox/update?send=true. Posting the same new message twice is refused with 409, which protects you from double-clicks.

7. Check what happened to it

Read the message back:

curl -sS "$API/crm/inbox/messages/6722b8e0f1a2b3c4d5e6f701" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"

Within a few seconds data.status becomes sent (the provider accepted it) or failed, and statusHistory holds an entry like {"status":"sent","statusText":"+13125550142: sent SM8f…"} with the provider's reference. A temporary provider error leaves it pending and AppEngine tries again; a permanent one marks it failed with the reason in statusText. Later, delivered arrives when the provider reports it.

Other places to look:

You wantCall
The inbox, newest firstGET crm/inbox/messages?p=1&ps=20
Threads per contact and channelGET crm/inbox/conversations
One threadGET crm/inbox/conversations/:parties?channel=sms (URL-encode the contact)
Email events for an address (delivered, bounced, opened)GET broadcast/[email protected]&limit=20
SMS historyGET crm/communications/sms

On a screen, show the status as it really is — "Queued", "Sent", "Delivered", "Failed: …" — so staff know whether to try again. After a network timeout on a send, read the inbox before sending again: the first request may already have gone out.

8. Send the monthly newsletter

A newsletter goes to hundreds of people, so it's a broadcast: one saved campaign, sent in runs, with a delivery row per recipient.

Create the draft in Studio. Open CRM → Broadcast → Create Broadcast and follow Choose the sender, Build the audience and Write the message, then Save Draft. For your first run, make the audience a short list of addresses you own. Keep the saved broadcast's sk as BROADCAST_ID.

Check the audience size:

curl -sS -X POST "$API/broadcast/audience/count" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" \
  -H 'Content-Type: application/json' --data '{"lists":["patients"]}'

It counts per list as a send would, leaving out anyone who unsubscribed, bounced or complained. GET broadcast/suppression shows who is on that do-not-send list and why.

Send it:

curl -sS -X POST "$API/broadcast/$BROADCAST_ID/send" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"
{ "message": "Broadcast queued for sending", "broadcastId": "6722c1…" }

Only a draft or cancelled broadcast can be sent; anything else returns 400. The first time, you may get 402 with code: "AGREEMENT_REQUIRED": bulk email is a paid service, and the organization's owner accepts its terms once. Show them the terms from GET org-management/services/agreement/bulk-email; when the owner agrees, POST org-management/services/agreement/accept/bulk-email records it (that is the acceptUrl in the error), and the send will go through.

Read the results. Each send is a run with its own runId. Stats and the report default to the latest run:

curl -sS "$API/broadcast/$BROADCAST_ID/stats" -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"
curl -sS "$API/broadcast/$BROADCAST_ID/report?page=1&pageSize=20" -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"

The report's broadcast block has the status, metrics, runId and a runs summary; recipients lists each person with status, error, from, provider, messageId, sentAt, deliveredAt, openCount and clickCount, and total is the full count. Add runId=… to both calls when you compare an older run.

POST broadcast/:id/cancel stops a run that is still sending. POST broadcast/:id/duplicate makes a fresh draft copy for next month.

9. Show a notice on the website

Over the holidays, Northside wants a banner: "Closed 24–26 December. Book online any time." That's a site notice — a message shown inside your own website, rather than sent to an inbox.

Create it in Studio with Create the content, choose the audience and display type, add a "Book now" link action with a callbackKey of book, and activate it.

Fetch what this visitor should see. Your website calls the feed with the site's name and the visitor's identity — the customer token for a signed-in visitor, or your site's app token for everyone else:

curl -sS "$API/notice/feed?site=northside&surface=site" \
  -H "orgid: $ORG" -H "Authorization: Bearer $CUSTOMER_TOKEN"
{
  "viewerId": "…",
  "sessionId": "…",
  "notices": [
    { "sk": "6722d0…", "data": { "title": "Holiday hours", "bodyText": "Closed 24–26 December. Book online any time.", "displayType": "banner", "actions": [{ "label": "Book now", "actionType": "link", "url": "/book", "callbackKey": "book" }] } }
  ]
}

The feed already applies the schedule, the audience and each notice's showFrequency, and leaves out notices this visitor has dismissed — on any device, because AppEngine remembers it, not a browser cookie. Your page only decides where to draw them.

Report what the visitor does, with the same identity:

WhenCall
the notice is on screenPOST notice/:id/display (counted once per session, so calling it on every page change is fine)
they click "Book now"POST notice/:id/click with {"actionKey":"book","url":"/book"}
they close itPOST notice/:id/dismiss
they accept or reject a consent noticePOST notice/:id/accept / POST notice/:id/reject (reject may return a redirectUrl)

After a dismiss, the feed stops returning that notice for this visitor. GET notice/inbox?site=northside still lists it, with a viewer block showing what they did and an unread count — use that for a "messages" page. Staff see the numbers with GET notice/:id/insights and the individual events with GET notice/activity?noticeId=…&event=click.

10. Provider callbacks

Delivery reports come back from providers to connect/webhook/:vendor/:serviceId? — for example SES and SendGrid report delivered, bounced and opened emails there, and AppEngine updates the matching message or broadcast_delivery record. Your email and phone set-up points the provider at this address; your own app only ever reads the updated records.

11. Find the rest in the API reference

Now that you know the pieces, search the reference for what you need:

curl -sS --get "$API/documentation/search" --data-urlencode 'q=broadcast'

Try q=inbox and q=notice too. Each result links to the route's full description.

12. Where to go next

  • Phone and SMS: the number behind your texts, and calls.
  • CRM APIs: the bookings and tickets that send messages for you.
  • Customer portal: show customers their own messages.
  • Marketing: campaigns and audiences beyond a single newsletter.
AppEngine Social Media, Ads, Marketing and Affiliate APIs — supporting guide

Companion source code

Course examples and setup on GitHub. This module shares its companion with the linked consolidated course.

Why this course

A business runs a promotion in several places at once: a page on its website, a social post, an email, maybe a paid ad, maybe a flyer in the shop window. The owner wants one answer to "how is the spring sale doing?" — what is running, what can be done next, and real numbers with their source.

You could build that yourself: a campaigns table, a state machine for draft → scheduled → active, a job that starts it at 9 a.m., code that talks to Facebook's ads API, a join between orders and campaign codes. AppEngine's Campaign Manager already does all of it. You create a campaign, call launch, and AppEngine works out which channel the campaign belongs to, does the launch through that channel, and hands back the campaign with its status, the actions allowed next and metrics gathered from every source that reports on it. Your code calls it; it does not rebuild it.

In this course you build the Spring Sale for a small online shop, from the first draft to the paid Facebook version, the replies to people who comment, and the end of the sale.

What is built in

Built-in data types

A data type (or datatype) is a ready-made kind of record with its fields already defined. Each saved record has an sk (its id) and a data object holding its fields. The marketing area uses these:

Data typeWhat it holdsA few real fields
campaignOne campaign, any channelname, type (email, social, ppc, display, video, radio, billboard, newspaper, magazine, tv, website), status, budget, budgetType, platforms, objective, landingPage, utmParameters, broadcastId
messageAn outgoing message; a social post draft is one of thesedeliveryType (the platform), status, direction, text, context
social_activityPosts, comments and messages synced from social accountsplatform, direction, content, authorName, campaignId
scheduleA timed action; a scheduled launch is one of thesestart.date, start.action, end, status
sf_orderA shop order; its attribution says which campaign brought itattribution.campaign, attribution.source, attribution.landingPage

The schemas live in the SDK (sdk/src/models/crm/crm-campaign.ts, sdk/src/models/storefront/sf-order.ts).

Built-in actions

All Campaign Manager routes sit under crm/marketing/campaign-manager.

You callAppEngine does for you
POST …/createchecks name, type and budget, saves the campaign as a draft, returns it with its channel, allowed actions and tracking
POST …/listtotals across every campaign (by status, budget, spend, conversions) plus one page of campaign cards
GET …/:idone campaign, with status, allowed actions, blockedActions and their reasons, metrics and where each number came from
POST …/:id/updatesaves only the editable fields; on a live ad campaign, also pushes a new name or budget to the ad platforms
POST …/social-draftsaves a social post draft linked to the campaign, ready to review in Social › Posts
POST …/:id/copysaves ad headlines, descriptions and call-to-action for one platform
POST …/:id/schedulechecks the start is in the future and (for ads) that every platform is ready, then books the launch and optional end
POST …/:id/launchlaunches through the right channel: creates the campaign on each ad platform, or queues the linked email broadcast, or marks an offline campaign running; a failed launch is recorded with the reason
POST …/:id/pause, /resume, /completepauses or restarts the campaign on every ad platform it is live on, or ends it and cancels pending schedules
POST …/:id/refresh-metricspulls spend, reach, impressions, clicks and conversions from each ad platform
POST …/:id/actualsrecords figures a person observed (for radio, print, flyers…)
GET …/ads-readiness/:idsays whether every ad platform the campaign targets can take it right now, and what fixes each problem
POST …/:id/test-launchcreates the campaign on Meta in paused status, so you can see the setup works before spending anything
POST sync/social-activities/replysends your reply to a social comment or message from the right page, and records it

Here is the first one, for real. The request:

POST/crm/marketing/campaign-manager/create

The response (shortened):

{
  "id": "6612f0c4a1b2c3d4e5f60718",
  "name": "Spring Sale",
  "type": "website",
  "typeLabel": "Website",
  "channel": "offline",
  "status": "draft",
  "statusLabel": "Draft",
  "platforms": [],
  "budget": 0,
  "budgetType": "lifetime",
  "metrics": { "spent": null, "reach": null, "impressions": null, "clicks": null, "conversions": 0, "revenue": 0 },
  "tracking": { "tracked": true, "sources": ["Checkout attribution"], "label": "Checkout attribution", "lastSyncedAt": null, "code": "spring-sale", "canRefresh": false },
  "failure": null,
  "platformIssues": [],
  "actions": ["launch", "schedule", "edit", "duplicate", "delete"],
  "blockedActions": {},
  "adsReadiness": null,
  "testLaunch": null
}

You sent eight fields; you got back a campaign that already knows its channel, what you may do next, and that orders tagged spring-sale will count as its conversions.

The rule

  1. Use the built-in action when one exists — create, launch, schedule, pause, record actuals, reply. Writing the campaign record directly would skip everything in the right-hand column: no validation, no channel launch, no schedule, no computed actions.
  2. Use the built-in data type for the thing itself. If your business needs an extra field on campaigns (say, storeRegion), add it to the organization's own fields for campaign — they are merged over the built-in collection — rather than inventing a my_campaigns collection.
  3. Create your own collection only for something AppEngine doesn't have — for example, a log of where flyers were handed out — and read and write it with the generic Repository routes (POST repository/find/:datatype, PUT repository/create). See Data records.

1. Set up the call

Every request needs two things:

  • orgid header — your organization ID. An organization is one business's private space on AppEngine; every record you create belongs to it.
  • Authorization: Bearer <token> — a bearer token is the string that proves who is calling. Here it is a staff token: the session token of a person allowed to manage marketing. A customer's token from the shop will be refused.

If you don't have these yet, create the organization and a staff session and read how sessions and organizations stay separate. Keep the staff token on your server; never send it to a browser.

Create .env.local (and keep it out of version control):

APPENGINE_ENDPOINT=https://appengine.appmint.io
ORG_ID=your-org-id
STAFF_TOKEN=your-staff-token

Save a small helper, api.mjs, that every later script uses:

export function required(name) {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

export async function api(method, path, body) {
  const response = await fetch(`${required('APPENGINE_ENDPOINT').replace(/\/+$/, '')}/${path}`, {
    method,
    headers: {
      orgid: required('ORG_ID'),
      Authorization: `Bearer ${required('STAFF_TOKEN')}`,
      ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const text = await response.text();
  let payload;
  try { payload = text ? JSON.parse(text) : null; } catch { payload = text; }
  if (!response.ok) {
    const error = new Error(`HTTP ${response.status}: ${payload?.message ?? response.statusText}`);
    error.status = response.status;
    error.body = payload;
    throw error;
  }
  return payload;
}

export const cm = (path = '') => `crm/marketing/campaign-manager${path}`;

The commands below use Node 20.6 or later (for --env-file).

2. Create the Spring Sale campaign

Save create.mjs:

import { writeFile } from 'node:fs/promises';
import { api, cm } from './api.mjs';

const campaign = await api('POST', cm('/create'), {
  name: 'Spring Sale',
  description: '20% off spring range, 1–14 April',
  type: 'website',
  landingPage: 'https://shop.example.com/spring',
  utmParameters: { source: 'site', medium: 'banner', campaign: 'spring-sale' },
  budget: 0,
  budgetType: 'lifetime',
  platforms: [],
});

await writeFile('campaign-id.txt', campaign.id);
console.log(campaign.id, campaign.channel, campaign.status, campaign.actions);
node --env-file=.env.local create.mjs

Three parts of the response carry the rest of this course:

  • channel — how a launch will be carried out. AppEngine works it out from type and platforms: email campaigns go through their linked email broadcast; campaigns whose platforms include an ad platform (facebook, instagram, google, tiktok, linkedin, pinterest, twitter) go through ads; everything else — radio, print, TV, and a website campaign with no ad platforms like ours — is offline, meaning launch simply marks it running.
  • actions — what may be done next, given the status. A draft offers launch, schedule, edit, duplicate, delete. Drive your buttons from this list instead of rebuilding the rules.
  • tracking.code — the code orders must carry to count as this campaign's sales. It is utmParameters.campaign when you set it, otherwise a slug of the name.

Create checks its input and answers 400 with a plain message when something is wrong — a missing name, a name over 200 characters, an unknown type, a negative budget. If the very same create arrives twice in a row (a double click), the second returns 409; read the list rather than creating again.

To see every campaign with totals, use the list:

const page = await api('POST', cm('/list'), { page: 1, pageSize: 20, status: 'draft', search: 'spring' });
console.log(page.summary.total, page.summary.draft, page.data.map((c) => c.name), page.hasNext);

summary covers every campaign in the organization (counts per status, total budget, spend, conversions); data is one page of campaign cards in the same shape as create returned.

3. Prepare the content

Edit the campaign

POST …/:id/update takes only the fields you want to change. Send them directly — not the whole campaign you read back:

import { readFile } from 'node:fs/promises';
import { api, cm } from './api.mjs';

const id = (await readFile('campaign-id.txt', 'utf8')).trim();
const updated = await api('POST', cm(`/${id}/update`), {
  description: '20% off the spring range, 1–14 April. Free delivery over $50.',
  tags: ['seasonal'],
});
console.log(updated.description);

The editable fields are name, description, type, objective, budget, budgetType, dailyBudget, platforms, landingPage, utmParameters, tags, notes, location, timeSlots, frequency, duration, audienceIds, targetAudience, audiences, timezone, broadcastId and creatives. Status, platform ids and metrics are set by the actions, so they are ignored here. A body with no editable field returns 400.

Draft the social post

The Spring Sale also goes out as a Facebook post. Save it as a draft linked to the campaign:

const draft = await api('POST', cm('/social-draft'), {
  campaignId: id,
  platform: 'facebook',
  text: 'Spring Sale starts today: 20% off the whole spring range until 14 April.',
  imageUrl: 'https://shop.example.com/img/spring-banner.jpg',
});
console.log(draft);
// { id: '…', platform: 'facebook', status: 'draft', text: '…', campaignId: '…',
//   openIn: { app: '/social-manager', tab: 'posts' } }

AppEngine saves a message record with deliveryType: 'facebook', status: 'draft' and the campaign in its context. A draft is never sent on its own: someone opens it in Social › Posts (the openIn value tells your app where), picks the page to post from, and publishes. platform is one of facebook, instagram, linkedin, twitter, tiktok, pinterest.

To see which social accounts are ready to post from, call GET …/social-feeds?limit=5. Each platform comes back with a state — connected, needs_reconnect, not_connected or no_account (connected, but no account chosen to sync) — its latest synced posts, and an action naming the fix. Connecting accounts is covered in Social Media.

Save ad copy

When the campaign will also run as an ad, save the copy for each platform:

await api('POST', cm(`/${id}/copy`), {
  platform: 'facebook',
  headlines: ['Spring Sale: 20% off', 'New season, lower prices'],
  descriptions: ['The whole spring range, until 14 April.'],
  cta: 'SHOP_NOW',
});

One copy set is kept per platform; saving again replaces it. You need at least one headline or description.

4. Launch — now or on a date

Launch now

const launched = await api('POST', cm(`/${id}/launch`));
console.log(launched.status, launched.startedAt, launched.actions);
// active  2026-04-01T09:00:12.000Z  ['pause', 'complete', 'enterActuals', 'edit', 'duplicate', 'delete']

What launch does depends on the channel:

ChannelWhat launch does
offline (our website campaign)marks the campaign active and records when and who
emailqueues the email broadcast whose id is in broadcastId; without one you get 400 asking you to link it
adscreates the campaign on each ad platform in platforms, stores each platform's campaign id in platformReferences, and marks it active — or failed with the reason (section 6)

For an email campaign, create the broadcast first (recipients, content, sender) as shown in Messaging, then set its id with update ({ "broadcastId": "…" }) before launch.

Launch is also how you retry a failed campaign. Launching a paused campaign resumes it.

Or schedule it

To start the sale at 9 a.m. on 1 April and end it on 14 April:

const scheduled = await api('POST', cm(`/${id}/schedule`), {
  startDate: '2026-04-01T09:00:00-05:00',
  endDate: '2026-04-14T23:59:00-05:00',
});
console.log(scheduled.status, scheduled.scheduledAt, scheduled.scheduledEndAt, scheduled.scheduleId);

AppEngine saves a schedule record owned by the campaign and sets the campaign to scheduled. At the start time the scheduler runs the same launch as above; at the end time it completes the campaign. The start must be in the future (400 otherwise — use launch to start now), and the end must be after the start. For an ad campaign, scheduling is refused until every platform is ready, so you find out now rather than at 9 a.m.

POST …/:id/unschedule cancels it and returns the campaign to draft. Launching a scheduled campaign by hand cancels its schedule. Scheduled launches run on AppEngine's background worker; Sync and scheduled jobs covers how that runs.

5. See how it is doing

Read the campaign at any time:

const c = await api('GET', cm(`/${id}`));
console.log(c.metrics, c.tracking);

metrics holds spent, reach, impressions, clicks, conversions and revenue. Each is added up from the sources that report on this campaign, and tracking.sources names them:

SourceWhere the numbers come from
Checkout attributionshop orders whose attribution.campaign equals the campaign's tracking.code (cancelled, refunded and draft orders are left out) → conversions, revenue
Linked social postssynced social_activity posts carrying this campaign's id → reach, impressions, clicks
Email broadcast statsthe linked broadcast's sent, delivered, opened and clicked counts
Ad platform insightsfigures pulled by refresh-metrics
Entered manuallyfigures a person recorded with actuals

A metric with no source is null, not 0. Show it as "not tracked" — a zero would claim nobody clicked, when really nothing is counting clicks.

Sales from the shop

For the Spring Sale, the shop's checkout passes the campaign code with the order. When your storefront calls POST storefront/checkout-cart, include the visitor's campaign details:

{
  "attribution": {
    "source": "site",
    "medium": "banner",
    "campaign": "spring-sale",
    "landingPage": "https://shop.example.com/spring?utm_campaign=spring-sale"
  }
}

AppEngine stamps this on the order's attribution field, and from then on the order counts toward the Spring Sale's conversions and revenue. The rest of the checkout call is taught in Headless storefront.

Figures nobody reports automatically

The shop also put flyers in the window and ran a local radio spot. Record what you observed:

await api('POST', cm(`/${id}/actuals`), {
  spent: 180,
  reach: 2500,
  impressions: 4000,
  note: 'Flyers (500) and two radio spots, from the station invoice',
});

Each call replaces the previous set of actuals, so send the full set every time — read c.actuals first if you are editing. Values must be zero or more.

Across all campaigns

GET crm/marketing/campaign-manager-performance returns the organization's totals, the top campaigns by conversions, the failed ones with their reasons and the ones nothing is tracking — a good feed for a dashboard tile.

6. Answer the people who reply

The Facebook post gets comments and private messages. AppEngine syncs them into social_activity and groups them into conversations.

const convos = await api('GET', 'sync/social-activities/conversations?page=0&pageSize=20');
const first = convos.data[0];
// { platform, customerId, customerName, accountName, activityType: 'comment' | 'message',
//   postId, lastMessageText, messageCount, hasAI }

const q = new URLSearchParams({ platform: first.platform, customer: first.customerId, page: '0', pageSize: '100' });
if (first.postId) q.set('postId', first.postId);
const thread = await api('GET', `sync/social-activities/conversation-thread?${q}`);
const last = thread.data.at(-1);

Pages start at 0 here. Add account=<page id> to the conversations call to see one page's conversations only. customerId is the person's id on the social platform, not a customer record.

To reply, name the message you are answering by its record id. AppEngine works out the platform, whether it is a comment or a private message, who to send to and which page to send from:

const result = await api('POST', 'sync/social-activities/reply', {
  activityId: last.sk,
  message: 'Thanks! The sale includes the linen range — free delivery over $50.',
  note: 'Asked about linen; pointed to the sale page',
});
if (!result.success) throw new Error(result.error);

This sends for real, signed with the name of the staff member whose token you used. Replies work for Facebook and Instagram comments and messages, WhatsApp messages and TikTok comments. The answer is { success, sentFrom?, error? } with HTTP 200 even when the send fails, so check success and show error — for example "Cannot reply to a comment without its comment id".

If an AI assistant answers this page's messages and a person wants to take over one conversation, put the conversation on hold:

await api('POST', 'sync/social-activities/ai-hold', {
  platform: first.platform, customerId: first.customerId, hold: true, reason: 'Wholesale enquiry, Ada handling',
});

The hold stops AI replies in that conversation only; tickets, notes and notifications carry on. It stays on until someone sends hold: false. GET sync/social-activities/ai-hold?platform=…&customerId=… shows the current state and who changed it.

7. Run the sale as a Facebook ad

The website sale works, so the owner wants a paid version. Start from a copy, so the original keeps its own numbers:

const ad = await api('POST', cm(`/${id}/duplicate`), { name: 'Spring Sale — Facebook' });
await api('POST', cm(`/${ad.id}/update`), {
  type: 'ppc', platforms: ['facebook'], objective: 'TRAFFIC', budget: 300, budgetType: 'lifetime',
});

duplicate copies the editable setup into a new draft and leaves the status, platform ids and results behind. With facebook in platforms, the channel is now ads.

Check readiness

Before an ad campaign can launch, the organization needs a connected Facebook login with ads permission and an ad account that can run ads. Ask AppEngine:

const readiness = await api('GET', cm(`/ads-readiness/${ad.id}?refresh=true`));
console.log(readiness.ready, readiness.blockedReason);
for (const p of readiness.platforms) console.log(p.label, p.status, p.issues.map((i) => i.message));

When something is missing, blockedReason is one sentence you can show by a disabled Launch button, naming each platform and its problem (for example, that no ad account is chosen), and each issue names the setting that fixes it. The same reasons appear in the campaign's blockedActions. Connecting the Facebook login is covered in Integrations and, from the operator's side, in Marketing & Ads.

Choose the ad account

const accounts = await api('GET', cm('/ad-accounts/facebook'));
for (const a of accounts.accounts) console.log(a.id, a.name, a.currency, a.selectable, a.reasons, a.fixUrl);

const chosen = accounts.accounts.find((a) => a.selectable);
await api('POST', cm(`/${ad.id}/ad-account`), { platform: 'facebook', accountId: chosen.id });

Only accounts with selectable: true can be chosen; for the others, reasons says why (no payment method, no permission…) and fixUrl is where to fix it on Facebook. Setting the account on the campaign affects only this campaign; POST …/ad-accounts/default changes the default for every campaign on that connection. Send accountId: null to go back to the default.

Test with a paused campaign

const tested = await api('POST', cm(`/${ad.id}/test-launch`));
console.log(tested.testLaunch.launches[0]);
// { connection: 'meta', campaignId: '1202…', adAccountId: 'act_…', status: 'PAUSED', managerUrl: 'https://adsmanager.facebook.com/…' }

AppEngine creates the campaign on Meta in PAUSED status with no budget and no ads, reads it back to confirm it is paused, and keeps its id. Open managerUrl to see it in Ads Manager. Remove it with POST …/:id/test-remove once you have seen it. Test launch is available for Facebook and Instagram campaigns.

Launch

const live = await api('POST', cm(`/${ad.id}/launch`));
console.log(live.status, live.failure, live.platformIssues);

This creates the real campaign on each ad platform with your budget, so the account can start spending. AppEngine creates the campaign object on each platform and keeps its id in platformReferences; who sees the ad and which creative it shows are finished in the platform's ads manager — see Marketing & Ads.

If a platform refuses, the call still returns the campaign, now with status: 'failed' and failure.reason in plain words ("Facebook rejected the launch: …"). Show the reason, fix it, and call launch again — that is the retry. If readiness was not met, launch answers 409 with reason: 'ads_not_ready' and the readiness report.

Once it runs, pull the platform's figures into the campaign:

const fresh = await api('POST', cm(`/${ad.id}/refresh-metrics`));
console.log(fresh.metrics, fresh.tracking.lastSyncedAt);

8. Change, stop and clean up

You callWhen
POST …/:id/pausestop an active campaign for now; ad platforms are paused, a queued email broadcast is cancelled
POST …/:id/resumerestart a paused campaign on its ad platforms
POST …/:id/completeend a running or paused campaign; ad platforms are paused and pending schedules cancelled
POST …/:id/update with name or budgeton a live ad campaign, the new name or budget is also sent to the platforms; if a platform refuses, the campaign shows it in platformIssues
POST …/:id/unscheduletake a scheduled campaign back to draft
DELETE crm/marketing/campaign-manager/:idremove a campaign; schedules are cancelled, a live ad campaign is paused on its platforms (its history stays in the ad account) and any paused test is removed

On 14 April the Spring Sale ends:

const done = await api('POST', cm(`/${id}/complete`));
console.log(done.status, done.completedAt, done.metrics);

A completed campaign still accepts actuals and, for ads, refresh-metrics, so late figures can still be added.

When something goes wrong, the answer tells you what to do:

You seeWhat it means
400 with a messagethe request or the current status doesn't allow it — e.g. "Only an active campaign can be paused", "The start must be in the future — use Launch to start now"
409 on create or duplicatethe same request just arrived twice; list campaigns instead of creating again
409 with reason: 'ads_not_ready'an ad platform needs attention; the body carries the readiness report
campaign with status: 'failed'the launch reached the platform or broadcast and was refused; failure.reason says why, and launch retries
success: false from replythe reply was not sent; show error

9. Going further

Find more routes. Now that you know what you're after, open the API reference at /documentation on your AppEngine server and search for campaign-manager or social-activities. Each route lists its body and response. API reference shows how to read it.

Generate a campaign with AI. POST crm/auto-campaign/sell-like-mad takes products (productIds or categorySlug), totalBudget, budgetType, durationDays, platforms and optional targeting and customInstructions, and returns a preview with copy and budget split per platform. GET crm/auto-campaign/preview/:previewId reads it, DELETE discards it, and POST crm/auto-campaign/approve with the previewId (and optional per-platform modifications and scheduleStartDate) launches it.

Other campaign routes. The API reference also lists crm/marketing/campaigns and crm/ads/campaigns. They read and write the same campaign records without the computed status, actions and metrics; build screens and integrations on Campaign Manager.

Related courses

AppEngine Content, Community and Learning APIs — supporting guide

Companion source code

Course examples and setup on GitHub. This module shares its companion with the linked consolidated course.

1. Why

Fern & Flame is a cooking school that wants to teach online. Students should work through a course called Knife Skills at their own pace, hand in a short written exercise that a chef reviews, unlock the next chapter only when the chef approves, and talk to each other in a private space for their cohort.

Behind that is a lot of backend: a course player that knows which lessons are open, progress that never goes backwards, quiz marking that keeps the answers secret, a review queue for the chefs, membership rules for the cohort space, posts, comments, reactions and moderation.

AppEngine has all of it. The school writes the course in Content Studio and sets up the cohort page; your app calls AppEngine to show the course, record progress, send answers, and run the community. This course follows Knife Skills from publishing to the first approved exercise and the first conversation in the cohort.

2. What is built in

Built-in data types

Data typeWhat it holdsSome of its real fields
postAn article or course as the author is writing it (the draft)title, slug, summary, coverImage, pages (each with id, title, content, type, duration, reviewers, answerKey), toc (the outline, with conditions per item), authenticationType, accessCode
publishedThe live copy of a post that learners and visitors readthe post's content, plus sourceDatatype: "post"
post_progressOne learner's enrollment in one coursepostId, status, enrolledAt, dueAt, completedAt, progress (per item: done, percent, score, review)
form_submissionA learner's answers to an exercisevalues, status
community_pageA community space: a group, a cohort, a brand pagetitle, type, visibility (public, private, invite_only), joinPolicy (open, approval_required, invite_only), postingPolicy (anyone, members, admins_only), moderationLevel (none, flagged_only, pre_approve)
community_page_memberOne person's membership of a pagepage, email, role, status
community_post / community_comment / community_reactionPosts, comments and reactionscontent, contentType, page, visibility, hashtags, mentions, poll, status
community_story, community_group_chat, community_messageShort-lived stories, group chats, direct messages—
creative_studioA design made in Creative Studiowork areas, canvas content, preview image

A record's ID is its sk; its business fields are inside data. A customer is a student or member who signs in to your app; a staff member works for the school.

Built-in actions

You callAppEngine does for you
POST repository/publish/post/:idCopies the current draft to the live published copy, so later edits stay private until you publish again
GET client/content-studio/:courseReturns the course, its outline with each item's status (locked, open, pending-review, changes, done) and why a locked item is locked, the learner's enrollment, and the next item to open
POST client/content-studio/:course/enrollChecks the course's access rule (open, access code, sign-in, invitation) and creates the learner's post_progress; enrolling twice returns the same enrollment
GET client/content-studio/:course/items/:itemIdReturns one lesson, only if it is open to this learner, with their saved answer and never the answer key
POST client/content-studio/:course/items/:itemId/progressRecords done or how much of a video was watched; the percentage never goes backwards; completes the course when every item is done
POST client/content-studio/:course/items/:itemId/answerSaves the learner's answers (as a draft or for real), marks quiz answers against the hidden answer key, and sends reviewed exercises to the review queue
GET content-studio/reviewLists every submission waiting for review, with the learner and their answers
POST content-studio/enrollments/:id/review/:itemIdRecords a reviewer's decision (approved or changes), checks the reviewer is allowed to mark that item, and unlocks whatever was waiting on approval
POST content-studio/:course/preview-keyIssues a one-hour key that lets your site show the unpublished draft without a staff sign-in
POST client/community/pages/:pageId/joinAdds the signed-in person to the page according to its join policy
POST client/community/pages/:pageId/inviteLets a page owner or admin add someone directly
POST client/community/postsChecks the page's posting policy, holds the post for approval on a pre-approve page, extracts #hashtags and @mentions, and saves it
GET client/community/feedReturns the posts this viewer may see — public posts, posts in pages they belong to, their own pending posts — with whether they liked or saved each
POST client/community/reactAdds a reaction, removes it if you send the same one again, or changes it, and keeps the counts
POST client/community/reports / POST community/reports/:id/reviewA member reports someone; staff review the report and record the outcome

3. Set up

You need the AppEngine address (API), your Organization ID (ORG, sent in the orgid header on every request), and three bearer tokens — strings that say who is calling:

  • STAFF_TOKEN — Chef Rosa, a staff member, from POST profile/user/signin.
  • ANA_TOKEN and BEN_TOKEN — two students, from POST profile/customer/signin.

The Developer Course and Foundations show how to get them.

Then, in Studio Manager:

  1. Write the course. In Content Studio, create a post titled Knife Skills with three pages: Holding the knife (a video), The claw grip (a short exercise with one text field named gripSummary), and Dicing an onion. On The claw grip, add Chef Rosa's email as a reviewer. On Dicing an onion, add the condition The claw grip is approved. Leave the course's access open. Content Studio and Publish Learning walk through the screens.
  2. Create the cohort page. Chef Rosa creates it through the API, which makes her the page's owner:
curl -sS -X POST "$API/client/community/pages" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"Knife Skills Cohort","type":"group","visibility":"private","joinPolicy":"invite_only","postingPolicy":"members","moderationLevel":"none"}'

The page can also be created and managed in Studio Manager's Community screens; see Community.

Keep the course's sk (or its slug, knife-skills) and the page's sk. Below they are COURSE and PAGE.

4. Preview, then publish

Before students see anything, Chef Rosa wants to check the draft on the real site. A staff member asks for a preview key:

curl -sS -X POST "$API/content-studio/knife-skills/preview-key" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"
{ "key": "m2x9k1c4.Yt7…", "expiresAt": "2026-10-08T17:12:00.000Z" }

Your site opens the draft with that key, using its own token (SITE_TOKEN — the site's app token or a signed-in visitor), not Chef Rosa's:

curl -sS --get "$API/client/content-studio/knife-skills/preview" --data-urlencode "key=$PREVIEW_KEY" \
  -H "orgid: $ORG" -H "Authorization: Bearer $SITE_TOKEN"

Every page is open in the preview, and each locked item carries opensWhen — the reason a student would wait — so you can check the conditions without enrolling a test account. Nothing is recorded. Keep preview keys out of public links; anyone holding one can read the draft for the hour.

When the draft is right, publish it:

curl -sS -X POST "$API/repository/publish/post/$COURSE" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"

Students now read the published copy. If Chef Rosa edits the draft tomorrow, students keep seeing today's version until she publishes again.

5. A student enrolls and works through the course

Ana opens the course page in your app. Your app reads the outline; on an open course this needs no enrollment, so the page can show what is inside before she joins:

curl -sS "$API/client/content-studio/knife-skills" -H "orgid: $ORG" -H "Authorization: Bearer $ANA_TOKEN"

She taps Enroll:

curl -sS -X POST "$API/client/content-studio/knife-skills/enroll" -H "orgid: $ORG" -H "Authorization: Bearer $ANA_TOKEN"
{
  "course": { "id": "66f3a1b2e4b0c81a2d3f6c01", "slug": "knife-skills", "title": "Knife Skills", "steps": 3, "layout": "sidebar" },
  "enrollment": { "id": "66f3b7d0e4b0c81a2d3f6c55", "status": "active", "enrolledAt": "2026-10-08T16:20:00.000Z", "percent": 0 },
  "items": [
    { "id": "holding", "title": "Holding the knife", "kind": "page", "type": "video", "status": "open", "percent": 0, "step": 1 },
    { "id": "claw", "title": "The claw grip", "kind": "page", "type": "form", "status": "open", "percent": 0, "step": 2 },
    { "id": "dicing", "title": "Dicing an onion", "kind": "page", "type": "video", "status": "locked", "lockReason": "Opens when The claw grip is approved", "percent": 0, "step": 3 }
  ],
  "next": "holding"
}

(Shortened; outline holds the same items as a tree with chapters.) Draw the player straight from items: show each status, show lockReason on locked items, and start at next. If the course needed an access code, add ?code=… to these requests; a wrong or missing code answers with a reason such as code-required that tells your app to show a code box.

Ana watches the first video. Your player reports how far she got, and that she finished:

curl -sS -X POST "$API/client/content-studio/knife-skills/items/holding/progress" \
  -H "orgid: $ORG" -H "Authorization: Bearer $ANA_TOKEN" -H 'Content-Type: application/json' \
  -d '{"percent":100,"done":true}'

The answer is the same view as above, with Holding the knife now done and next moved on. A later report of 40% will not undo the 100%.

6. She hands in the exercise

The claw grip has a form. Your app opens the item to show its content and any answer she saved before:

curl -sS "$API/client/content-studio/knife-skills/items/claw" -H "orgid: $ORG" -H "Authorization: Bearer $ANA_TOKEN"

She writes a few lines and saves a draft (done: false), so she can come back to it:

curl -sS -X POST "$API/client/content-studio/knife-skills/items/claw/answer" \
  -H "orgid: $ORG" -H "Authorization: Bearer $ANA_TOKEN" -H 'Content-Type: application/json' \
  -d '{"values":{"gripSummary":"Fingertips tucked, knuckles guide the blade."},"done":false}'

Then she submits it (done: true). The keys in values are the form's field names. Because the page has a reviewer, it does not become done; it goes to review, and the item's status in the returned view is pending-review. AppEngine stored her answer as a form_submission belonging to her enrollment.

While it waits, and once it is approved, the answer is closed: sending again answers 409 with a message you can show as it is. On a quiz page with an answer key, AppEngine marks the answers itself and returns the result; the score can never come from your app.

7. The chef reviews it

Chef Rosa's review screen lists everything waiting:

curl -sS "$API/content-studio/review" -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"
[
  {
    "enrollmentId": "66f3b7d0e4b0c81a2d3f6c55",
    "postId": "66f3a1b2e4b0c81a2d3f6c01",
    "itemId": "claw",
    "person": { "datatype": "customer", "id": "66f1c2a8e4b0c81a2d3f4a4e", "email": "[email protected]", "name": "Ana Silva" },
    "answer": { "gripSummary": "Fingertips tucked, knuckles guide the blade." },
    "submittedAt": "2026-10-08T16:31:12.000Z"
  }
]

She asks for a little more:

curl -sS -X POST "$API/content-studio/enrollments/66f3b7d0e4b0c81a2d3f6c55/review/claw" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" -H 'Content-Type: application/json' \
  -d '{"status":"changes","note":"Good. Add where your thumb goes."}'

Ana's item now shows changes with Chef Rosa's note, and she can edit and submit again. When Chef Rosa sends {"status":"approved"}, the item becomes done, and Dicing an onion opens because its condition — is approved — now holds. Only people listed as the page's reviewers (by email or group) and admins can decide; anyone else gets 403.

To see the whole class, staff use GET content-studio/knife-skills/enrollments (filter with status and search). To enroll a list of students directly, POST content-studio/knife-skills/enrollments with {"emails":[…]}. A student's own dashboard of courses is GET client/content-studio/mine.

8. Bring the cohort together

The cohort page is private and invite-only, so the page owner adds each student. Chef Rosa created it, so she is its owner:

curl -sS -X POST "$API/client/community/pages/$PAGE/invite" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]"}'

The answer is Ana's community_page_member record with status: "active". On an open page a student joins herself with POST client/community/pages/:pageId/join; on an invite-only page that answers 403 "An invitation is required". (An approval_required page puts self-joiners in pending, and the customer API has no approve step, so for a cohort use invite-only as here.)

Ben is not invited yet. When he opens the page or its feed, he gets 404 — the same answer as a page that does not exist, so private spaces stay private. Chef Rosa then invites Ben the same way, and the page opens for him.

9. Post, comment and react

Ana posts in the cohort:

curl -sS -X POST "$API/client/community/posts" \
  -H "orgid: $ORG" -H "Authorization: Bearer $ANA_TOKEN" -H 'Content-Type: application/json' \
  -d '{"page":"'"$PAGE"'","content":"Finally got the claw grip! #knifeskills"}'
{
  "sk": "66f3c9a4e4b0c81a2d3f7d20",
  "datatype": "community_post",
  "data": {
    "content": "Finally got the claw grip! #knifeskills",
    "page": "66f3a9e1e4b0c81a2d3f6d10",
    "visibility": "page",
    "hashtags": ["knifeskills"],
    "author": "[email protected]",
    "status": "active"
  }
}

AppEngine checked she is a member (the posting policy is Members), took the hashtag from the text, and set visibility: "page" because the post is in a page. Had the page used pre_approve moderation, status would be pending and only Ana would see it while it waits for moderation.

The cohort feed for this page:

curl -sS --get "$API/client/community/feed" --data-urlencode "page=$PAGE" --data-urlencode 'sort=latest' --data-urlencode 'limit=20' \
  -H "orgid: $ORG" -H "Authorization: Bearer $ANA_TOKEN"

Here page is the community page's ID; the result page number is pageNum. Each post carries viewerLiked and viewerSaved for whoever is asking, so fetch the feed again when someone else signs in on the same device.

A classmate comments, and replies use parentComment:

curl -sS -X POST "$API/client/community/posts/66f3c9a4e4b0c81a2d3f7d20/comments" \
  -H "orgid: $ORG" -H "Authorization: Bearer $BEN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"content":"Nice! Try it with a tomato next."}'

And reacts:

curl -sS -X POST "$API/client/community/react" \
  -H "orgid: $ORG" -H "Authorization: Bearer $BEN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"target":"66f3c9a4e4b0c81a2d3f7d20","targetType":"post","type":"like"}'
{ "action": "added", "type": "like" }

Sending the same reaction again answers removed; sending a different one answers changed. Use action to set the button, rather than flipping it yourself.

Authors change their own posts with PUT client/community/posts/:postId (AppEngine marks them edited) and remove them with DELETE, which sets status: "removed" and hides the post. Anyone else trying either gets 403.

10. When something goes wrong: reports and moderation

If a member is behaving badly, another member reports them:

curl -sS -X POST "$API/client/community/reports" \
  -H "orgid: $ORG" -H "Authorization: Bearer $BEN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"reportedId":"[email protected]","reason":"spam","details":"Posting links to an unrelated shop","context":{"source":"post","messageId":"66f3d0b1e4b0c81a2d3f7e01"}}'

Reasons are spam, harassment, inappropriate, unwanted_contact and other. Staff see open reports with GET community/reports and close each with POST community/reports/:id/review, body {"action":"dismissed"} or {"action":"action_taken","notes":"Removed the posts and warned the member"}. Reviewing records the decision; removing posts or members is a separate step the page admin takes.

11. More of the community, when you need it

The same customer API covers the rest of a social space. Each follows the pattern above: the signed-in person is the author, and AppEngine applies the page's rules.

FeatureRoutes
PollsPOST client/community/posts with contentType: "poll" and a poll object; POST client/community/posts/:postId/vote with {optionId} (one vote per person)
StoriesPOST client/community/stories (text or media; gone after 24 hours unless highlighted), GET client/community/stories?page=…, POST …/stories/:storyId/view
Group chatsPOST client/community/groups with title and members, then `POST
Direct messagesPOST client/community/messages with recipientId, GET client/community/messages/thread/:userId; live delivery on the /community-chat socket — see Realtime
Photos and videoPOST client/community/media/upload (multipart, field file), then put the returned file in the post's media
Follows and bookmarksPOST client/community/follow, `GET

For the course's artwork, staff can make the cover in Creative Studio, which has its own API (POST creative-studio/designs/create, …/designs/:id/save, …/designs/list). See Creative Studio.

12. Finding more endpoints

You now know what to look for. The API reference AppEngine serves at /documentation (for the hosted platform, https://appengine.appmint.io/documentation) lists every route above with its body and response. Search content-studio for the course player and review routes, or community for pages, posts and stories:

curl -sS --get "$API/documentation/search" --data-urlencode 'q=community posts' --data-urlencode 'limit=5'

The API Reference course shows the rest of the reference.

13. Where to go next

  • Publish Learning — building courses, conditions and reviewers in Content Studio.
  • Community — running pages and moderation in Studio Manager.
  • Realtime — live messaging and presence for the community.
  • File Storage — media, private files and signed links.
  • Messaging APIs — email your students when a review comes back.
  • Custom Web and Mobile Client — wrap these calls in a full app.
AppEngine Analytics and Platform Administration APIs — supporting guide

Companion source code

Course examples and setup on GitHub. This module shares its companion with the linked consolidated course.

1. Why

The owner of Harbor Bikes wants one screen that answers "how is the business doing?": who visited the website this month, which pages they read, who is on the site right now, how sales and support are going, and what the platform is costing them. Building that yourself means a tracking script, a store for millions of page events, aggregation jobs, comparisons with last month, and a cache so the screen loads fast.

AppEngine already does all of it. Every page view on a business's site is recorded as it happens, and the reports are computed on request — with the previous period and the change worked out for you. Your dashboard asks for a report and draws it.

In this course you build that owner dashboard for Harbor Bikes, then cover the two administration jobs that sit next to it: moving records between organizations and checking the platform's health.

2. What is built in

Built-in data types

AppEngine records what happens on a site as web_visit records — one per event (a page view, a page leave, a click). Each holds, among other fields:

FieldMeaning
typepageview, unload, click…
url, pageTitle, referrerThe page and where the visitor came from
deviceId, sessionIdA stable ID for one browser, and one visit's session
duration, scrollDepthTime on page and how far they scrolled (sent on leave)
country, city, browser, os, deviceTypeLooked up from the request
siteName, hostWhich of the organization's sites it was
utmSource, utmMedium, utmCampaignCampaign tags from the link

The other reports are computed from the business's own built-in records: orders (sf_order), leads (lead), tickets (ticket), workflow tasks, automations and emails. You never write report rows yourself; you write (or the built-in actions write) the business records, and the reports read them.

Built-in actions

You callAppEngine does for you
POST tools/web-visit-indirectRecords one page event for a site; drops bots, probes and local hosts, records an identical event in the same 30 seconds once, and adds location from the IP
GET analytics/sitesLists the organization's sites, which is the default, and whether each has a blog
GET analytics/websiteComputes visitors, page views, sessions, bounce rate, top pages, traffic sources, devices, locations and campaigns for one site and date range — plus the same figures for the period before, and the change for each
GET analytics/storefront, leads, tickets, blog, email, workflow, automation, users, attributionThe same for sales, the sales pipeline, support, the blog, email campaigns, workflows, automations, accounts and campaign attribution
GET analytics/live-viewLists the visitors active in the last few minutes, each with their current page, location and how long they have been there
GET analytics/live-view/:deviceId/journeyReturns one visitor's timeline of events and a summary: first and last seen, page views, places, sources
GET studio-overview/:sectionComputes the summary figures behind a Studio Manager section (crm, store, finance, events, community…)
POST stats/:datatype/:id/like (and bookmark, follow, rating, view, share…)Records one person's engagement with a record and keeps its counters
GET usage/statement, usage/spend-controls, usage/statsReport what the organization has been charged, its spend limit and its usage
POST org-management/transfer/requestsAsks another organization to accept a copy (or move) of chosen records; nothing moves until they accept
GET monitoring/health, monitoring/queues, monitoring/jobsReport the service's health and its background job queues

Reports are cached for up to five minutes, so a page view you make now appears in analytics/website within that window. Live View is refreshed much more often.

3. Set up: an organization, a staff token and a site with visits

You need the AppEngine address (API), your Organization ID (ORG, sent in the orgid header so AppEngine knows which business you mean), and a staff token — a bearer token you get by signing in as a staff member with POST profile/user/signin. If you have not done this yet, the Developer Course and Foundations show how.

Reports need something to report on. Harbor Bikes has a published site (see Sites and Rendering). Sites hosted by Appmint record visits automatically. If you render the site yourself, send one event per page view from your server:

curl -sS -X POST "$API/tools/web-visit-indirect" \
  -H "orgid: $ORG" -H 'Content-Type: application/json' \
  -d '{"data":{"type":"pageview","configSite":"harbor-bikes","host":"harborbikes.example","url":"https://harborbikes.example/rentals","pageTitle":"Rentals","deviceId":"dev_7Kq2M9","sessionId":"ses_41"}}'

The answer is true when the event was recorded and false when it was filtered out (a bot, a local address, a repeat within 30 seconds). Generate a deviceId once per browser and reuse it, so one person's pages join into one journey. If configSite or host does not belong to the organization, you get 404 SITE_NOT_FOUND.

Open the site in a browser and visit two or three pages so there is something to see.

4. Pick the site

A site name is the site's machine name, such as harbor-bikes — not its display title. Ask which sites there are:

curl -sS "$API/analytics/sites" -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"
{
  "sites": [
    { "name": "harbor-bikes", "title": "Harbor Bikes", "domains": ["harborbikes.example"], "isDefault": true, "blogEnabled": true, "blogPage": "news" }
  ],
  "defaultSite": "harbor-bikes"
}

Fill your dashboard's site picker from sites, and start on defaultSite.

5. Show the website report

Ask for the site's report for October:

curl -sS --get "$API/analytics/website" \
  --data-urlencode 'siteName=harbor-bikes' \
  --data-urlencode 'startDate=2026-10-01' \
  --data-urlencode 'endDate=2026-10-31' \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"
{
  "site": { "name": "harbor-bikes", "title": "Harbor Bikes" },
  "overview": { "totalVisitors": 412, "pageViews": 1380, "sessions": 530, "bounceRate": 41.2, "avgSessionDuration": 96, "conversionRate": null },
  "traffic": [ { "date": "2026-10-01", "value": 38 }, { "date": "2026-10-02", "value": 44 } ],
  "topPages": [ { "page": "/rentals", "title": "Rentals", "views": 310, "uniqueViews": 220, "avgDuration": 48, "avgScrollDepth": 62, "bounceRate": 35 } ],
  "trafficSources": [ { "source": "Google", "sessions": 210, "percentage": 39.6 } ],
  "period": { "startDate": "2026-10-01T00:00:00.000Z", "endDate": "2026-10-31T23:59:59.999Z" },
  "comparison": {
    "startDate": "2026-08-31T00:00:00.000Z",
    "endDate": "2026-09-30T23:59:59.999Z",
    "overview": {
      "totalVisitors": { "previous": 365, "changePercent": 12.9, "direction": "up", "sentiment": "good" },
      "bounceRate": { "previous": 44.8, "changePercent": -8, "direction": "down", "sentiment": "good" }
    }
  }
}

(Shortened; the full response also has devices, browsers, countries, cities, campaigns, entry and exit pages, hour-of-day and day-of-week breakdowns.) What you get for free:

  • Dates. A plain date like 2026-10-31 means the whole of that day. Leave both dates out and you get the last 30 days.
  • Comparison. comparison is the same length of time just before your range, with each figure's previous value, percentage change, direction, and whether that direction is good — bounce rate going down is good. Draw the arrows straight from it.
  • Site. site says which site the figures are for. If the siteName you sent is unknown, you get the default site, so label the panel from site.title, not from what you asked for.
  • Nothing to measure. conversionRate is null when the site has no conversion signal to count; show "not tracked" rather than 0.

If the request fails, show that the report could not load and keep the user's filters; do not draw zeros.

The other reports work the same way with the same date parameters: analytics/storefront (revenue, orders, average order value, product performance), analytics/leads (sources, funnel; add pipelineId for one pipeline's stage funnel and tz for the viewer's time zone), analytics/tickets, analytics/email, analytics/blog (takes siteName too), analytics/workflow, analytics/automation, analytics/users. Call the one each panel needs.

6. Who is on the site right now

Live View lists the visitors seen in the last few minutes, across all of the organization's sites:

curl -sS --get "$API/analytics/live-view" --data-urlencode 'minutes=15' \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"
{
  "minutes": 15,
  "count": 1,
  "visitors": [
    { "data": { "deviceId": "dev_7Kq2M9", "url": "https://harborbikes.example/rentals", "city": "Portland", "country": "United States" },
      "entryUrl": "https://harborbikes.example/", "pageviews": 3, "online": true,
      "firstSeen": "2026-10-08T15:02:11.000Z", "lastSeen": "2026-10-08T15:06:40.000Z" }
  ]
}

Click a visitor to open their journey:

curl -sS --get "$API/analytics/live-view/dev_7Kq2M9/journey" --data-urlencode 'limit=100' \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"

The answer is { deviceId, timeline: { events }, summary }: the events newest first, and a summary with first and last seen, page views, sessions, places and sources. A device ID is a browser, not a customer: one person can have several, and most visitors never sign in.

7. Add the business summaries

For the rest of the dashboard, ask for the same figures the Studio Manager sections show:

curl -sS "$API/studio-overview/store" -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"

The answer starts with { "section": "store", "generatedAt": "…" } followed by that section's figures. The sections are home, crm, store, finance, events, community, logistics, dam, build-studio, account, config, database and ai-automation; each has its own shape, so read one before you draw it. An unknown section answers 404.

If your app shows content people can like, save or rate (a blog post, a product), the stats routes keep those counts per record. POST stats/post/:id/like with {"action":"add"} records the signed-in person's like and {"action":"remove"} takes it back; GET stats/post/:id returns the counts (likes, views, bookmarks, averageRating…) and what the signed-in person has done.

8. Export

POST analytics/export takes the same options as the reports, in the query string, plus type and format:

curl -sS --get -X POST "$API/analytics/export" \
  --data-urlencode 'type=website' --data-urlencode 'format=json' \
  --data-urlencode 'siteName=harbor-bikes' \
  --data-urlencode 'startDate=2026-10-01' --data-urlencode 'endDate=2026-10-31' \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" -o harbor-october.json

It returns { format, data, exportedAt } as JSON, with data holding the report. Save it as a .json file. If the owner wants a spreadsheet, turn the rows you need (such as topPages) into CSV in your app.

9. What the platform costs

The owner's account panel needs three reads, all for the organization in orgid:

  • GET usage/statement?from=2026-10-01&to=2026-10-31 — every change to the balance in that window, labelled with what each charge was for.
  • GET usage/spend-controls — the monthly spend limit, this month's spend and the spend notifications.
  • GET usage/stats?startDate=…&endDate=… — usage figures for the organization. Leave out the all option; it is for platform operators looking across every organization.

GET service-pricing/catalog lists the services the organization can be charged for and their rates. Show the values these return rather than fixed numbers in your app.

10. Finding more endpoints

You now know what you are looking for. The API reference AppEngine serves at /documentation (for the hosted platform, https://appengine.appmint.io/documentation) lists every report and its parameters. Search analytics to see them all, or search for a single panel:

curl -sS --get "$API/documentation/search" --data-urlencode 'q=analytics leads' --data-urlencode 'limit=5'

Open a result to see its query parameters and the full response. The API Reference course covers the reference in depth.

11. Administration: move records to another organization

Sometimes records belong in a different organization — a design made in an agency's organization that the client now owns, say. AppEngine handles this as a transfer request: the source organization asks, the target organization accepts, and only then are records copied. Both sides need a configuration administrator (the ConfigAdmin role, or a platform root role).

From the source organization, check the target exists, then ask:

curl -sS "$API/org-management/transfer/requests/check-org/client-org-id" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN"

curl -sS -X POST "$API/org-management/transfer/requests" \
  -H "orgid: $ORG" -H "Authorization: Bearer $STAFF_TOKEN" -H 'Content-Type: application/json' \
  -d '{"targetOrgId":"client-org-id","assets":{"post":["66f2a0c1e4b0c81a2d3f5b10"]},"mode":"copy","reason":"Handing over the launch article","message":"Please accept the article"}'

assets maps each data type to the record IDs to send. mode is copy (the source keeps its record) or move (the source record is removed after the copy). The answer is the request, with its ID.

In the target organization, an administrator signs in with their own token and lists what is waiting:

curl -sS --get "$API/org-management/transfer/requests" --data-urlencode 'direction=incoming' --data-urlencode 'status=pending' \
  -H "orgid: $TARGET_ORG" -H "Authorization: Bearer $TARGET_STAFF_TOKEN"

They read one with GET …/requests/:id, then POST …/requests/:id/accept to copy the records in, or POST …/requests/:id/reject with {"reason":"…"}. The source can withdraw a pending request with POST …/requests/:id/cancel. If the records are of a custom data type, create the same collection in the target organization first.

12. Administration: check the platform's health

If you run your own AppEngine, or support people who do, the monitoring routes answer "is it the platform or is it my request?":

RouteAnswers
GET monitoring/healthIs the service up, and are its dependencies healthy
GET monitoring/overview, GET monitoring/system-metrics, GET monitoring/historical?range=1hSystem resource figures, now and over a time range
GET monitoring/queues, GET monitoring/queues/:queueNameHow many background jobs are waiting, running and failed in each queue
GET monitoring/jobs?queue=…&status=failed&page=1&pageSize=20The individual jobs, with their errors
POST monitoring/jobs/:queueName/:jobId/retry (or /remove)Runs a failed job again, or removes it

A queue is a list of background jobs — sending an email, a scheduled report — that AppEngine works through after the request that created them has returned. When a report looks wrong, check in order: the source records and the date range, then the HTTP status of the report request, then the queues. Retry a job only when you know running it twice is safe; a retried email is sent again. These routes describe the whole server, so keep them in operator tools and out of a customer-facing app. The Scheduled Jobs course shows a job's life end to end.

13. Where to go next