A publishing connection is where approved articles go live. You set it up once, in Settings under Connections, or during setup. pangoling never publishes anything you have not approved, unless you turn on auto-publish yourself.
Most connections have a Test connection button. Use it before you save. Shopify and Git / Markdown have none, so for those the first publish is what tells you a credential is wrong. Either way, run the launch check next, so you know articles render properly on the real site.
Credentials are stored encrypted, and each one is only ever sent to the host it was saved for.
Pick your platform
| Your site runs on | Use | Publishes as |
|---|---|---|
| WordPress (self-hosted or WordPress.com) | WordPress | a post or a page |
| Ghost | Ghost | a post |
| Webflow | Webflow | a live item in your blog collection |
| HubSpot CMS | HubSpot | a blog post, published or scheduled |
| Notion, or a Notion-powered site (Super, Potion) | Notion | a page in a database |
| Shopify | Shopify | a page |
| A static site built from a GitHub repo (Next.js, Astro, Hugo, Eleventy) | Git / Markdown | a Markdown or MDX file, committed |
| Anything else with a developer behind it | Custom site (webhook) | whatever your endpoint does with it |
If you do not want pangoling to publish at all, choose No publishing. Drafts stay in pangoling for you to copy out.
WordPress
- In WordPress admin, open Users, then Profile (your own user).
- Scroll to Application Passwords, type a name like "pangoling", and click Add New.
- Copy the generated password, spaces included.
Then fill in:
- Site URL: your WordPress home URL, with no trailing slash.
- Username: the login the application password belongs to.
- Post type:
postsfor blog posts,pagesfor pages. - Application password: the one you just copied.
Ghost
- In Ghost admin, open Settings and find Integrations (under Advanced in recent versions).
- Click Add custom integration, name it "pangoling", and create it.
- Copy the Admin API key (it looks like
id:secret) and the API URL.
Paste the site URL (no trailing slash) and the Admin API key.
Webflow
- In Webflow, open Site settings, then Apps & integrations, then API access. Generate a token with CMS read and write access.
- Open your blog CMS collection and copy its Collection ID from the collection settings.
- Check the slug of the rich-text field your blog template renders. It is often
post-body.
Fill in the collection ID, the body field slug, the site URL and the blog path segment (for example blog). If you set a summary field, it receives the meta description.
HubSpot
- In HubSpot, go to Settings, then Integrations, then Private Apps. Create an app with the CMS content scope and copy its access token. It starts with
pat-. - Find your blog ID under Settings, Website, Blog. It is the number in the address bar when you edit the blog.
Paste both.
Notion
- Create an internal integration at notion.so/my-integrations and copy its secret.
- Open the target database, click the ••• menu, then Connections, and add your integration.
- Copy the database ID from its URL. It is the 32-character part before any
?.
Fill in the database ID and the name of its title column. If you also give a slug column, republishing a post replaces the old page instead of creating a second one.
Shopify
Create an app for your store in Shopify that can write pages, then fill in:
- Store domain: for example
brand.myshopify.com. - Client ID and Client secret from that app.
- Page template suffix (optional): use it if your theme has a separate page template for articles.
Git / Markdown (static sites)
For sites that build from Markdown files in a GitHub repository. Each article is committed as one file, and the commit triggers your normal build and deploy. Content stays versioned in your repo.
To give pangoling access, use Connect GitHub on the connection form, or Install the GitHub App where it is offered, in which case commits appear under the app's bot name. If you would rather use a token, paste one under Advanced: a fine-grained token with Contents: write on the repo, or a classic token with the repo scope.
| Field | Example | What it is |
|---|---|---|
| Repo | acme/website | owner and name |
| Branch | main | the branch your site builds from |
| Content directory | content/blog/{lang} | where article files go; {lang} is replaced with the article's language |
| File extension | mdx | md or mdx |
| Frontmatter date field | date | the key your site reads the publish date from |
| Date format | iso | iso for a full timestamp, date for YYYY-MM-DD |
| Site URL | https://example.com | used to build the live link |
| Blog URL path | blog | the path segment articles live under; {lang} allowed |
| Default locale | en | optional; dropped from {lang} URLs |
Each file opens with frontmatter your templates can read: title, slug, lang, your date field, description, category, author, readingMinutes, tags, keywords and relatedPages. description is always present, and empty lists are written as [] rather than left out, so a template that reads tags.length does not break the build.
If one language lives under a different path, for example Swedish at sv/blogg, set per-locale path overrides as JSON: {"sv":"sv/blogg"}.
Custom site (webhook)
For a custom or database-backed site. Your developer adds one endpoint, and pangoling sends each approved article to it as JSON.
- Add an endpoint that checks an
Authorization: Bearer <token>header and accepts a JSON body. - Save the post, and reply with JSON that includes the live
url. - In pangoling, paste the endpoint URL and choose a long random string as the bearer token.
A minimal Next.js endpoint:
// app/api/ingest/route.ts
export async function POST(req: Request) {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
if (token !== process.env.INGEST_TOKEN) return new Response('Unauthorized', { status: 401 });
const body = await req.json();
if (body.ping) return Response.json({ ok: true }); // Test connection sends this
const { slug, title, html, markdown, metaDescription } = body;
// Save the post in your database here.
return Response.json({ url: `https://yoursite.com/articles/${slug}` });
}
What each article sends:
| Field | Type | Notes |
|---|---|---|
slug | string | the URL slug |
title | string | |
html | string | the body, rendered |
markdown | string | the same body, as Markdown |
metaDescription | string or null | |
language | string | for example en |
contentType | string | for example blog, pillar, glossary |
category | string or null | |
targetKeyword | string or null | |
tags | string array | |
ogImage | string or null | image URL |
author | object or null | present only when bylines are on |
scheduledFor | string or null | ISO date, only if your endpoint releases scheduled posts. Kept for compatibility: store publishedAt instead |
noindex | boolean | true only for the launch check's test article |
contractVersion | number | 2 |
keywords | string array | the target keyword |
publishedAt | string or null | ISO date of the first publish. Store it on first insert and never overwrite it |
updatedAt | string or null | ISO modified date, set only after a real refresh. Null means never updated; do not default it to publishedAt |
canonical | string or null | canonical URL, when the piece points at another page |
authorUrl | string or null | the "How we write" page, for the byline link |
readingMinutes | number | |
imageAlt | string or null | alt text for a generated cover |
relatedPages | array | same-site pages the body links to, as { url, title }; always an array |
entities | array | the same pages as { name, url }; always an array |
breadcrumbs | string array | trail entries as Name|/path, home first; may be empty |
breadcrumbSchema | string or null | the trail as a BreadcrumbList JSON string; null whenever jsonLd is sent, because the document already carries the trail |
faqSchema | string or null | FAQPage JSON-LD, when the piece has an FAQ |
jsonLd | string or null | a complete schema.org JSON-LD document to print in the page head |
indexNow | object | { key, path }; present only when your site has a key. Serve path with the key as its body |
idempotencyKey | string | stable per piece and payload; also sent as the Idempotency-Key header. Drop repeats |
source | string | always markos |
Ignore any key you do not know. Prefer entities and relatedPages over text matching when you build related-content blocks.
Any 2xx response counts as published. If the response is JSON with url (and optionally id), pangoling links to the live page. The default method is POST; you can switch it to PUT.
There is no delete call in this contract, so the launch check cannot remove its test article from a webhook site. It stays with a noindex request until you delete it by hand.