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.
Build an order link
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.
$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:
$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:
$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:
$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.