Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

Catalog sync

Some payment providers keep their own product catalog. Sync keeps that catalog and yours in step. This page is for the store administrator who sells through such a provider.

Before you begin

  • A payment provider that declares the sync_products capability. Of the first-party four, only Polar does. Stripe and PayPal have no catalog, and the offline method has no provider.
  • The scheduler running, because push and pull both go through the job queue.

Sync directions

Direction is configured per provider in the base plugin's config file, user/config/plugins/kahunacart.yaml, not in the provider plugin's:

YAML
sync:
  polar:
    direction: remote
Direction Meaning
local Your catalog is master. Products are pushed out. Every admin product write queues a push job.
remote The provider is master. Its catalog is pulled in, and its catalog webhooks apply locally.
off Default. Nothing runs automatically. Anything unrecognized reads as off.

Direction governs automatic work only. A manual bin/plugin kahunacart sync --push or --pull always does exactly what you asked.

Note

These keys are YAML-only. They are provider slugs the base plugin cannot know in advance, so there is no admin field for them.

What syncs

Products sync. Orders, customers and stock do not.

For each product, sync carries title, type (digital or physical), status (published, draft or archived), summary, description, and the default variant's price_minor, sku and title.

The provider only translates. The base plugin owns direction policy, the job queue, the kahunacart_provider_map bookkeeping, and every local write.

Push your catalog to the provider

Set direction to local to make your catalog master. Every admin product create or update then queues a sync.push_product job for each local-master provider that can sync.

The provider returns a remote id, recorded in the mapping table against your local product id. A push failure is wrapped with the provider slug, and the job retries with backoff.

To push the whole catalog at once:

BASH
bin/plugin kahunacart sync --provider=polar --push

The command walks the catalog in batches of 100 and reports the count.

How Polar avoids duplicate products

Each pushed product is stamped with kahunacart_slug metadata, and a later push looks the product up by that metadata rather than creating a second one. The local summary, sku and type ride along in metadata too, so they survive the round trip.

An update replaces a Polar product's whole price list, so the price is sent only when it has actually moved.

The plugin enforces Polar's constraints before calling out: titles must be at least 3 characters, names are truncated to 64 characters, and metadata values to 500. Polar visibility maps from local status: published becomes public, anything else private, and archived sets the archived flag.

Pull the provider's catalog

Set direction to remote to make the provider master. Its catalog webhooks then apply locally. A product.created or product.updated event either carries the normalized product, which is applied directly, or carries nothing, in which case a full sync.pull_catalog job is queued.

To pull the whole catalog at once:

BASH
bin/plugin kahunacart sync --provider=polar --pull

A pulled product is matched against the mapping table by its remote id:

Case Result
Mapped, and the local product still exists Updated in place.
Mapped, but the local product was deleted The orphaned mapping row is dropped, then a new product is created.
Unmapped Created, with a slug derived from the title and de-duplicated (widget, widget-2, widget-3).

Pulls never delete local products. A provider dropping a product from its catalog leaves the local row alone.

Anything missing or unrecognized degrades safely. An unknown type reads as physical, an unknown status as draft, and an empty remote_id skips the entry.

What Polar sends

Polar sells digital goods, so a pulled product is digital unless this plugin pushed it as physical earlier. Archived Polar products arrive as archived, and non-public ones as draft.

When a provider is not master

A catalog webhook from a provider whose direction is not remote does not overwrite anything. The mapping row is marked stale instead, signalling that the two catalogs have diverged. Run bin/plugin kahunacart sync --status to see the counts per state.

If the remote id is not in the mapping table at all, nothing happens.

The default-variant limitation

A pull applies the normalized data to the product and to its default variant only: price, sku and title. Additional variants are recorded verbatim in the mapping row's data_json under extra_variants and are not created as local variants.

A provider product with three prices therefore becomes one local product with one variant at the default price, and the other two prices sit in the mapping row.

Important

You cannot currently pull a multi-variant catalog and get a multi-variant catalog. Multi-variant sync lands with the variant-options work in a later phase.

The push side has the same limitation from the other direction. pushProduct() receives the product plus all its variants, but what a provider does with them is up to the provider. Polar has no variant concept: each price on a Polar product becomes one local variant on the way in, and archived prices are dropped unless that would leave none.

Tip

If your catalog is genuinely multi-variant, set direction to off and manage both sides by hand until the variant work lands.

The Polar carrier-product model

Polar's checkout API always references a catalog product, and a session listing several products makes the customer choose one rather than buying them all. A multi-line KahunaCart cart cannot be expressed as a list of Polar products.

Polar does support an ad-hoc price that overrides the catalog price for a single session. The plugin therefore sends exactly one product, the carrier, priced at the whole order total, plus the order hash and transaction hash in metadata.

The carrier is either the product you name in {mode}_checkout_product_id, or one the plugin finds or creates: a private product called KahunaCart Order with a placeholder $1.00 price that is never charged.

This has two consequences:

  • Polar's order records show the carrier product, not what was bought. Your KahunaCart order is the itemized record, and the order hash lines the two up.
  • A store can sell through Polar without syncing its catalog. The carrier does not depend on any mapping, so direction: off is a good production setting for a Polar store that only wants payments.

Sync CLI commands

BASH
# Show mappings, capabilities and configured directions for every provider
bin/plugin kahunacart sync

# Same, filtered to one provider
bin/plugin kahunacart sync --provider=polar

# Push the whole local catalog out
bin/plugin kahunacart sync --provider=polar --push

# Pull the provider's catalog in
bin/plugin kahunacart sync --provider=polar --pull

Running sync with no action flag reads as --status. --push and --pull are mutually exclusive, and either one requires --provider. Add --status to a push or pull to print the resulting state afterwards.

The status table shows, per provider: how many products are mapped and in what state, which sync capabilities it declares, whether it implements the sync interface, and its configured direction. See CLI.

Failure modes

Error Cause
Unknown payment provider: X No provider registered under that slug. Check the plugin is installed, enabled, and configured. Providers only register once their credentials are present.
Provider 'X' does not sync products The provider does not declare sync_products or does not implement the sync interface.
Cannot push product N to 'X': product not found The product was deleted between the job being queued and run.
Provider 'X' returned an empty remote id for product N A provider bug. The mapping is not written.
Could not find a free slug for 'Title' 200 slug collisions on one title during a pull. Almost certainly a runaway pull loop.

Push and pull jobs go through the job queue, so a failing sync retries with exponential backoff and lands in the failed bucket with its last_error after three attempts.