Video chapters
- AppEngine Data APIs: One Pattern for Every Module
- Prepare the companion and accounts
- Connect the example client
- Find the current contract
- Inspect the actual campaign schema
- One record keeps its identity
- One organization, separate authenticated people
- Create the campaign draft
- Adapt the repository query
- Find and reopen the campaign
- Change the description; retain the rest
- Save one precise change
- Restore a disposable draft
- Launch performs the campaign operation
- Launch, pause and resume
- A community post carries membership and authorship
- Create the community page
- Join and publish as a customer
- Read the feed pagination contract
- Page through the member feed
- Save a comment and verify its count
- A report moves from the member to staff review
- Submit a customer report
- Record the staff review
- Find the next module in the reference
- 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.

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.


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.

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.

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.


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.


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.

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.

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.




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.


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:
| Feature | Find in the reference | Consequential distinction |
|---|---|---|
Messages and templates (message, messagetemplate) | CRM Communications, Message Templates, Inbox | Saved content, queued send and delivered message are different outcomes. |
| Broadcasts and notices | Broadcast, Notices | Audience and delivery behavior belong to module actions. |
Campaigns, social and ads (campaign) | CRM Marketing, Social, Ads | Launch/publish needs channel configuration and platform results. |
| Affiliates and enrichment | Affiliate, Data Enrichment | Use each action's prerequisites and returned result. |
Content and community (community_post) | Content Studio, Community, Comments | Publication, membership and moderation affect what the customer sees. |
| Learning | Content Studio participant and staff routes | Enrollment, answer and review are a sequence, not arbitrary progress edits. |
Analytics and administration (usage record; computed reports remain separate) | Analytics, Stats, Usage, Monitoring, Organization Management | Reports 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.

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.
- 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.
- Open the companion README. From its
browserfolder, runAPPENGINE_API_ORIGIN=https://YOUR_API_ORIGIN node server.mjsusing Node 20 or later. Replace the example origin with your assigned backend. - 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.
- 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.
- 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.
| Implementation | Source |
|---|---|
| Browser interface and local proxy | Interface JavaScript, proxy server, HTML, styles |
| JavaScript command-line client | run.mjs |
| Python client | run.py |
| TypeScript integration methods | client.ts |
| JSON request bodies and actor requirements | operations.json |
| cURL and Postman | cURL 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), adatatype(its kind) and adataobject with its fields. - Your organization ID goes in the
orgidheader 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
| Datatype | What it is | Fields you will use |
|---|---|---|
message | one email or SMS, sent or received | deliveryType (email, sms, …), from, to, subject, text, html, status, statusHistory, direction, context |
messagetemplate | a reusable message with placeholders | name, subject, html, text, deliveryType |
email_broadcast | a campaign to a list | name, channels, senderAccountIds, recipientList, emailConfig, smsConfig, status, metrics |
broadcast_delivery | one recipient's copy of a campaign | recipient, status, messageId, provider, error, sentAt, deliveredAt, openCount, clickCount |
site_notice | a banner, pop-up or toast on your website | title, 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 call | AppEngine does for you |
|---|---|
POST crm/test-template | renders a template with sample values and lists any placeholder left empty; sends a test only if you ask |
POST crm/send-template | finds the template, refuses to send if any placeholder has no value, then queues the message |
POST crm/inbox/update?send=true | saves a one-off email or SMS, queues it, retries on temporary provider errors, records the outcome |
GET crm/inbox/messages/:id | the message with its current status and history |
GET crm/inbox/conversations | the inbox grouped into threads per contact and channel |
POST broadcast/audience/count | counts who a campaign would reach, leaving out unsubscribed and bounced addresses |
POST broadcast/:id/send | checks the campaign is a draft and the bulk-email agreement is accepted, then queues the run |
GET broadcast/:id/report, GET broadcast/:id/stats | per-recipient results and totals for one run |
GET notice/feed | the notices this visitor should see now, with ones they already dealt with left out |
POST notice/:id/display, click, accept, reject, dismiss | records what the visitor did, which decides whether the notice shows again |
The rule
- Use the built-in action. If you create a
messagerecord withoutsend=true, nothing goes out; if you call a provider yourself, the message never reaches the inbox, the customer's history or the delivery reports. - Use the built-in data types. Write your wording as a
messagetemplate, your campaign as anemail_broadcast, your banner as asite_notice— all the screens and reports read them. - 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 9For 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:
- Email: set up the clinic's email service and a sending address by following Choose the email service through Create a mailbox. For newsletters, also complete bulk sending through AWS SES.
- SMS: a phone number registered for texting, from the Phone APIs course.
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 want | Call |
|---|---|
| The inbox, newest first | GET crm/inbox/messages?p=1&ps=20 |
| Threads per contact and channel | GET crm/inbox/conversations |
| One thread | GET 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 history | GET 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:
| When | Call |
|---|---|
| the notice is on screen | POST 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 it | POST notice/:id/dismiss |
| they accept or reject a consent notice | POST 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 type | What it holds | A few real fields |
|---|---|---|
campaign | One campaign, any channel | name, type (email, social, ppc, display, video, radio, billboard, newspaper, magazine, tv, website), status, budget, budgetType, platforms, objective, landingPage, utmParameters, broadcastId |
message | An outgoing message; a social post draft is one of these | deliveryType (the platform), status, direction, text, context |
social_activity | Posts, comments and messages synced from social accounts | platform, direction, content, authorName, campaignId |
schedule | A timed action; a scheduled launch is one of these | start.date, start.action, end, status |
sf_order | A shop order; its attribution says which campaign brought it | attribution.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 call | AppEngine does for you |
|---|---|
POST …/create | checks name, type and budget, saves the campaign as a draft, returns it with its channel, allowed actions and tracking |
POST …/list | totals across every campaign (by status, budget, spend, conversions) plus one page of campaign cards |
GET …/:id | one campaign, with status, allowed actions, blockedActions and their reasons, metrics and where each number came from |
POST …/:id/update | saves only the editable fields; on a live ad campaign, also pushes a new name or budget to the ad platforms |
POST …/social-draft | saves a social post draft linked to the campaign, ready to review in Social › Posts |
POST …/:id/copy | saves ad headlines, descriptions and call-to-action for one platform |
POST …/:id/schedule | checks the start is in the future and (for ads) that every platform is ready, then books the launch and optional end |
POST …/:id/launch | launches 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, /complete | pauses or restarts the campaign on every ad platform it is live on, or ends it and cancels pending schedules |
POST …/:id/refresh-metrics | pulls spend, reach, impressions, clicks and conversions from each ad platform |
POST …/:id/actuals | records figures a person observed (for radio, print, flyers…) |
GET …/ads-readiness/:id | says whether every ad platform the campaign targets can take it right now, and what fixes each problem |
POST …/:id/test-launch | creates the campaign on Meta in paused status, so you can see the setup works before spending anything |
POST sync/social-activities/reply | sends your reply to a social comment or message from the right page, and records it |
Here is the first one, for real. The request:
/crm/marketing/campaign-manager/createThe 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
- Use the built-in action when one exists — create, launch, schedule, pause, record actuals, reply. Writing the
campaignrecord directly would skip everything in the right-hand column: no validation, no channel launch, no schedule, no computed actions. - 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 forcampaign— they are merged over the built-in collection — rather than inventing amy_campaignscollection. - 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:
orgidheader — 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-tokenSave 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.mjsThree parts of the response carry the rest of this course:
channel— how a launch will be carried out. AppEngine works it out fromtypeandplatforms:emailcampaigns go through their linked email broadcast; campaigns whoseplatformsinclude 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 offerslaunch,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 isutmParameters.campaignwhen 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:
| Channel | What launch does |
|---|---|
| offline (our website campaign) | marks the campaign active and records when and who |
queues the email broadcast whose id is in broadcastId; without one you get 400 asking you to link it | |
| ads | creates 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:
| Source | Where the numbers come from |
|---|---|
| Checkout attribution | shop orders whose attribution.campaign equals the campaign's tracking.code (cancelled, refunded and draft orders are left out) → conversions, revenue |
| Linked social posts | synced social_activity posts carrying this campaign's id → reach, impressions, clicks |
| Email broadcast stats | the linked broadcast's sent, delivered, opened and clicked counts |
| Ad platform insights | figures pulled by refresh-metrics |
| Entered manually | figures 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 call | When |
|---|---|
POST …/:id/pause | stop an active campaign for now; ad platforms are paused, a queued email broadcast is cancelled |
POST …/:id/resume | restart a paused campaign on its ad platforms |
POST …/:id/complete | end a running or paused campaign; ad platforms are paused and pending schedules cancelled |
POST …/:id/update with name or budget | on 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/unschedule | take a scheduled campaign back to draft |
DELETE crm/marketing/campaign-manager/:id | remove 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 see | What it means |
|---|---|
400 with a message | the 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 duplicate | the 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 reply | the 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
- Messaging — build the email broadcast an email campaign launches.
- Headless storefront — the checkout that carries campaign attribution.
- Integrations — connect Facebook and other providers.
- Sync and scheduled jobs — how social activity is synced and schedules fire.
- Social Media and Marketing & Ads — the same work from the operator's screens.
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 type | What it holds | Some of its real fields |
|---|---|---|
post | An 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 |
published | The live copy of a post that learners and visitors read | the post's content, plus sourceDatatype: "post" |
post_progress | One learner's enrollment in one course | postId, status, enrolledAt, dueAt, completedAt, progress (per item: done, percent, score, review) |
form_submission | A learner's answers to an exercise | values, status |
community_page | A community space: a group, a cohort, a brand page | title, 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_member | One person's membership of a page | page, email, role, status |
community_post / community_comment / community_reaction | Posts, comments and reactions | content, contentType, page, visibility, hashtags, mentions, poll, status |
community_story, community_group_chat, community_message | Short-lived stories, group chats, direct messages | — |
creative_studio | A design made in Creative Studio | work 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 call | AppEngine does for you |
|---|---|
POST repository/publish/post/:id | Copies the current draft to the live published copy, so later edits stay private until you publish again |
GET client/content-studio/:course | Returns 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/enroll | Checks 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/:itemId | Returns 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/progress | Records 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/answer | Saves 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/review | Lists every submission waiting for review, with the learner and their answers |
POST content-studio/enrollments/:id/review/:itemId | Records 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-key | Issues a one-hour key that lets your site show the unpublished draft without a staff sign-in |
POST client/community/pages/:pageId/join | Adds the signed-in person to the page according to its join policy |
POST client/community/pages/:pageId/invite | Lets a page owner or admin add someone directly |
POST client/community/posts | Checks 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/feed | Returns 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/react | Adds 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/review | A 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, fromPOST profile/user/signin.ANA_TOKENandBEN_TOKEN— two students, fromPOST profile/customer/signin.
The Developer Course and Foundations show how to get them.
Then, in Studio Manager:
- 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. - 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.
| Feature | Routes |
|---|---|
| Polls | POST client/community/posts with contentType: "poll" and a poll object; POST client/community/posts/:postId/vote with {optionId} (one vote per person) |
| Stories | POST client/community/stories (text or media; gone after 24 hours unless highlighted), GET client/community/stories?page=…, POST …/stories/:storyId/view |
| Group chats | POST client/community/groups with title and members, then `POST |
| Direct messages | POST client/community/messages with recipientId, GET client/community/messages/thread/:userId; live delivery on the /community-chat socket — see Realtime |
| Photos and video | POST client/community/media/upload (multipart, field file), then put the returned file in the post's media |
| Follows and bookmarks | POST 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:
| Field | Meaning |
|---|---|
type | pageview, unload, click… |
url, pageTitle, referrer | The page and where the visitor came from |
deviceId, sessionId | A stable ID for one browser, and one visit's session |
duration, scrollDepth | Time on page and how far they scrolled (sent on leave) |
country, city, browser, os, deviceType | Looked up from the request |
siteName, host | Which of the organization's sites it was |
utmSource, utmMedium, utmCampaign | Campaign 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 call | AppEngine does for you |
|---|---|
POST tools/web-visit-indirect | Records 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/sites | Lists the organization's sites, which is the default, and whether each has a blog |
GET analytics/website | Computes 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, attribution | The same for sales, the sales pipeline, support, the blog, email campaigns, workflows, automations, accounts and campaign attribution |
GET analytics/live-view | Lists 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/journey | Returns one visitor's timeline of events and a summary: first and last seen, page views, places, sources |
GET studio-overview/:section | Computes 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/stats | Report what the organization has been charged, its spend limit and its usage |
POST org-management/transfer/requests | Asks another organization to accept a copy (or move) of chosen records; nothing moves until they accept |
GET monitoring/health, monitoring/queues, monitoring/jobs | Report 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-31means the whole of that day. Leave both dates out and you get the last 30 days. - Comparison.
comparisonis 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 isgood. Draw the arrows straight from it. - Site.
sitesays which site the figures are for. If thesiteNameyou sent is unknown, you get the default site, so label the panel fromsite.title, not from what you asked for. - Nothing to measure.
conversionRateisnullwhen 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.jsonIt 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 thealloption; 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?":
| Route | Answers |
|---|---|
GET monitoring/health | Is the service up, and are its dependencies healthy |
GET monitoring/overview, GET monitoring/system-metrics, GET monitoring/historical?range=1h | System resource figures, now and over a time range |
GET monitoring/queues, GET monitoring/queues/:queueName | How many background jobs are waiting, running and failed in each queue |
GET monitoring/jobs?queue=…&status=failed&page=1&pageSize=20 | The 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
- Analytic Center — the same reports in Studio Manager, for checking your dashboard against them.
- CRM APIs, Headless Storefront and Finance APIs — create the leads, orders and payments that the reports count.
- Marketing APIs — campaigns whose UTM tags show up in
trafficSourcesandutmAttribution. - Realtime — talk to a Live View visitor through chat.
- Platform as a Service — environments, builds and logs for hosted apps.