Skip to content

Powered by Grav CMS + Helios Theme

Signed links and rate limits

The storefront provides three shared services for plugins to use: the signed links that open an order, the rate limiter, and the resolver that decides which address a request came from. This page covers the developer APIs. For merchant configuration, see Hardening a KahunaCart store.

Before you begin

  • KahunaCart installed and enabled.
  • A plugin, theme or template of your own.
  • Every example starts from the KahunaCart::instance() facade.

Every URL that opens an order carries a signature that expires. Build one through orderLinks(), never by hand. A hand-built link has no token and lands on the expired-link page.

PHP
$links = KahunaCart::instance()->orderLinks();

$links->receipt($order);      // {site}/shop/order/{hash}?t=…
$links->invoice($order);      // …/invoice
$links->invoicePdf($order);   // …/invoice.pdf
$links->pay($order);          // …/pay

Each method takes the order row or its hash. link($purpose, $order) is the same call by name, and purposes() lists the four.

Purpose Path Lifetime from
receipt {base}/order/{hash} security.order_link_days
invoice {base}/order/{hash}/invoice security.invoice_link_days
invoice.pdf {base}/order/{hash}/invoice.pdf security.invoice_link_days
pay {base}/order/{hash}/pay orders.pay_link_days

orderLinks() builds against the absolute site root, which is what an email, an admin "copy link" field and an MCP answer need. A page building links for itself calls withPrefix() first with its own root-relative, language-prefixed base.

untokenized($purpose, $order) returns the same URL with no token. Use it in the account area only, where the customer's session already authorises them.

SignedToken mints the signature from a per-site secret. KahunaCart creates it once from 32 random bytes and keeps it in the kahunacart_settings table; KahunaCart::instance()->siteSecret() reads it. Nothing rotates it, and deleting the row invalidates every link the store has already emailed.

Warning

Never put the secret, or a token minted from it, anywhere a customer can read it other than in its own link. A token is scoped to one purpose and one order hash; the secret opens everything.

Count a request against a bucket

Requests to routes vulnerable to repeated calls are counted per client address in a named bucket. Call limit() and act on the result:

PHP
$result = KahunaCart::instance()->limit(RateLimits::CART_WRITE);

if (!$result->allowed) {
    // answer 429 with Retry-After: $result->retryAfter
}

The second argument adds whatever else separates one caller from another. The three buckets that answer an order hash pass the hash, so one address sweeping many orders is limited per order as well as per address:

PHP
$result = $kahunacart->limit(RateLimits::ORDER_LOOKUP, $order['hash']);

The plugin counts in these buckets, with their shipped defaults:

Bucket Constant Covers Default
cart_write RateLimits::CART_WRITE Add, update, remove, apply a coupon 60 per minute
checkout RateLimits::CHECKOUT The checkout submit 10 per minute
order_lookup RateLimits::ORDER_LOOKUP Receipt and pay pages, by hash 30 per minute
invoice RateLimits::INVOICE The invoice and its PDF 20 per minute
download RateLimits::DOWNLOAD Download links 30 per minute
link_request RateLimits::LINK_REQUEST "Email me a new link" 3 per hour
fragment RateLimits::FRAGMENT Estimate, search and filter fragments 120 per minute
account_offer RateLimits::ACCOUNT_OFFER The post-checkout account offer 3 per hour
wishlist_write RateLimits::WISHLIST_WRITE Every wishlist write: hearting, moving to the cart, renaming a list 60 per minute
review_submit RateLimits::REVIEW_SUBMIT Review submissions 5 per hour
magic_link_request RateLimits::MAGIC_LINK_REQUEST "Email me a sign-in link" 5 per hour
magic_link_consume RateLimits::MAGIC_LINK_CONSUME Opening a sign-in link 20 per hour
cart_restore RateLimits::CART_RESTORE Opening a cart-restore link and answering its email challenge 10 per hour
agent_read RateLimits::AGENT_READ Product feeds, storefront MCP reads, UCP GETs and catalog calls 120 per minute
agent_write RateLimits::AGENT_WRITE Agent carts and checkouts: create, update, cancel 60 per minute
agent_complete RateLimits::AGENT_COMPLETE Completing an agent checkout 10 per minute
agent_profile_fetch RateLimits::AGENT_PROFILE_FETCH The store's own fetches of platform UCP profiles, counted store-wide 30 per minute

RateLimits::buckets() returns the list, and security.rate_limits.<bucket>.limit and .window override any of them. A limit of zero or less turns a bucket off.

An add-on may count in a bucket name of its own. An unknown name gets the strictest shipped default rather than no limit, so a typo limits a route too hard rather than not at all. Give the bucket its own default in security.rate_limits if the strictest one is wrong for it.

Counts live in the kahunacart_rate_limits table, so every PHP worker and node counts against the same rows. Old rows are swept as a side effect and need no cron.

Read the client address

Behind Cloudflare, a load balancer or an ingress controller, REMOTE_ADDR is the proxy, so every visitor would share one bucket. Ask KahunaCart instead:

PHP
$ip = KahunaCart::instance()->clientIp();

clientIp() resolves once per request and caches the answer. It reads X-Forwarded-For only when the connection came from an address listed in security.trusted_proxies. With that list empty (the default) the header is ignored. When the connection is trusted, the chain is walked from the right past every trusted-proxy hop, and the first address that is not one is the client. An address that cannot be resolved comes back as ClientIp::UNKNOWN.

ClientIp::resolve($server, $trustedProxies) and ClientIp::matches($ip, $range) are static and take plain arrays, so you can test them without a request. IPv4 and IPv6, single addresses and CIDR ranges are all accepted.

Important

Never read X-Forwarded-For yourself. If trusted unconditionally, the client can set this header to bypass your rate limit.