Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

EtsyGate

A self-hosted PHP gate for delivering Etsy digital products. Buyers verify their purchase against the Etsy v3 API and receive a signed, expiring download link. No Etsy SDK, no framework, no database.

But Why

Etsy caps digital file uploads at 20 MB per file. Anything larger cannot be delivered through Etsy at all, so the common workaround is to upload a small PDF containing a link to the real file hosted somewhere else. That link is public. Anyone who comes across it can download the product without buying it, and it stays valid forever.

EtsyGate is a gate in front of that link. Instead of a static URL, the buyer gets a page where they enter their Order ID and Transaction ID. EtsyGate checks the pair against the Etsy API and, only on a match, issues a download link that expires after a configurable number of minutes. The files sit in a directory that is never served directly; PHP streams them, so there is no permanent public URL to leak in the first place.

To be clear, this only stops the link itself from being public. It does not stop a buyer who has already downloaded the file from passing it around afterwards. The goal is to close the "anyone with the URL" hole, not to control the file after delivery.

How it works

  1. The buyer opens index.html and enters their Order ID and Transaction ID.
  2. public/download.php receives the request and passes both values to PurchaseVerifier.
  3. PurchaseVerifier calls EtsyClient, which requests getShopReceiptTransaction (GET /v3/application/shops/{shop_id}/transactions/{transaction_id}) from the Etsy API.
  4. The transaction's receipt_id is compared against the Order ID the buyer supplied. A mismatch, or an unknown transaction, ends the request.
  5. On a match, TokenManager builds a token containing the receipt ID, transaction ID, listing ID, and an expiry timestamp, signed with HMAC-SHA256 using TOKEN_SECRET.
  6. The buyer receives a download link carrying that token, valid for TOKEN_EXPIRY_MINUTES minutes.
  7. Opening the link returns to download.php, which verifies the signature and expiry, resolves the listing ID to a local file through products/map.php, and streams it via FileDelivery.
  8. Expired or tampered tokens produce a plain error page. No file is served.

Why Order ID and Transaction ID

The obvious design was to verify against the buyer's email address. Etsy exposes buyer_email on the receipt schema, but the field is nullable, and access is granted case by case (subject to Etsy's approval). Applications without that grant always read null, which makes email verification impossible to rely on.

Order ID plus Transaction ID works with the standard transactions_r scope that any app can request. Both values appear in the buyer's Etsy order confirmation. See the Security section for what this does and does not protect against.

Requirements

  • PHP 8.1 or newer with the curl and json extensions
  • Composer
  • Apache with mod_rewrite, or an equivalent rewrite configuration
  • An Etsy developer app with OAuth2 credentials

Installation

git clone <repo-url> etsygate
cd etsygate
composer install
cp .env.example .env
chmod 600 .env

.env must be writable

.env is not just read at startup. Etsy access tokens are short-lived, and when one expires EtsyClient fetches a replacement and writes the new ETSY_ACCESS_TOKEN and ETSY_REFRESH_TOKEN straight back into .env. The PHP process therefore needs both read and write permission on that file.

A read-only .env fails in a way that is easy to misread, because the first request still succeeds. The refresh runs, the new token is held in memory, that request completes normally, and the write is what silently fails. The damage shows up afterwards: Etsy rotates the refresh token on every exchange, so the value still sitting in the unwritten .env has already been spent. The next request loads that dead token, tries to refresh with it, and gets rejected. From then on every request fails with a 401 and the only fix is re-running php auth/setup.php.

Configuration

1. Register an Etsy app

Create an app at etsy.com/developers/.

Note the keystring and shared secret.

In the app settings, find the Callback URLs field and add one:

https://yourdomain.com/callback

Save the app. This URL does not need to serve anything. Etsy only uses it as the destination it redirects your browser to after you approve the app, and auth/setup.php reads the authorization code out of that redirect. A path that returns a 404 works fine.

2. Set ETSY_REDIRECT_URI

ETSY_REDIRECT_URI in .env must be byte-for-byte identical to the Callback URL you just registered:

ETSY_REDIRECT_URI=https://yourdomain.com/callback

Etsy compares this value at two separate points, when it builds the authorization screen and again during the token exchange, and rejects the request if the two do not match exactly. A trailing slash, http instead of https, or a different subdomain will all fail with a redirect URI mismatch error. If setup fails at the authorization step, check this value first.

3. Fill in the rest of .env

Key Description
ETSY_API_KEY Your app's keystring
ETSY_SHARED_SECRET Your app's shared secret
ETSY_REDIRECT_URI Must match the Callback URL registered above, exactly
ETSY_ACCESS_TOKEN Written by auth/setup.php. Leave blank
ETSY_REFRESH_TOKEN Written by auth/setup.php. Leave blank
SHOP_ID Written by auth/setup.php. Leave blank
TOKEN_SECRET Written by auth/setup.php. Leave blank
TOKEN_EXPIRY_MINUTES Lifetime of a download link, in minutes. Default 15
DEBUG Writes API traffic to storage/logs/debug.log. Keep false in production

In practice you only need to fill in the first three. TOKEN_SECRET is generated for you during setup, so leave it blank unless you have a specific key you want to use.

If you would rather supply your own, it must be at least 32 characters:

php -r "echo bin2hex(random_bytes(32));"

4. Run the OAuth setup

php auth/setup.php

The script prints an authorization URL. Open it, approve the app, then copy the full URL your browser lands on and paste it back into the prompt.

Setup then writes four values to .env for you:

  • ETSY_ACCESS_TOKEN and ETSY_REFRESH_TOKEN, from a PKCE code exchange
  • TOKEN_SECRET, generated with bin2hex(random_bytes(32)) if it is currently blank or absent. An existing value is never overwritten, since replacing it would break every download link already issued
  • SHOP_ID, resolved automatically from your Etsy account

The script is CLI-only and needs to run just once. Requested scopes are transactions_r, listings_r, and shops_r.

Adding products

Place your files in storage/files/, then map each Etsy listing ID to its file in products/map.php:

return [
    1234567890 => __DIR__ . '/../storage/files/my-product.zip',
    9876543210 => __DIR__ . '/../storage/files/another-product.pdf',
];

Security

Why the gate exists. Etsy's native digital delivery hands out permanent links. EtsyGate's links stop working after TOKEN_EXPIRY_MINUTES, so a shared or intercepted link is only useful inside that window.

Token model. Tokens are self-contained: a base64url payload holding the receipt ID, transaction ID, listing ID, and expiry timestamp, followed by an HMAC-SHA256 signature over that payload keyed with TOKEN_SECRET. Nothing is stored server-side. Verification recomputes the signature and compares it with hash_equals, a timing-safe comparison, and only then decodes the payload and checks the expiry. Altering any field invalidates the signature, so a buyer cannot extend their own expiry or swap in another listing ID.

The whole model rests on TOKEN_SECRET being secret and unguessable. An empty or short key would make signatures cheap to reproduce, which would let anyone craft a working download URL. Three things prevent that: auth/setup.php generates a 64-character key from random_bytes(32) when none is set, TokenManager refuses to start if the key is missing or under 32 characters, and config/etsy.php asserts notEmpty() on every required variable. Never hand-write the key.

Rotating TOKEN_SECRET invalidates every outstanding link immediately.

File access. Product files are served only by FileDelivery, which resolves the path with realpath() and refuses anything that does not sit inside storage/files/, blocking traversal through a malicious map entry. Layered on top: per-directory .htaccess files denying all access, plus rewrite rules in the root .htaccess returning 403 for internal paths and any dotfile.

What verification does and does not prove. Verification confirms that the submitted Transaction ID belongs to the submitted Order ID in your shop. It does not prove the person submitting them is the buyer. Both values appear on the order confirmation, so anyone holding a copy of that confirmation can pass the gate. Because buyer_email is gated behind Etsy's approval process, this is the strongest check available to a standard app, and the expiry window is what limits the exposure. Set TOKEN_EXPIRY_MINUTES accordingly.

Token refresh. Etsy access tokens are short-lived. When a request comes back 401, EtsyClient exchanges ETSY_REFRESH_TOKEN for a new pair, writes both back to .env, and retries the request once. This happens transparently, so setup does not need re-running as tokens age.

Etsy rotates the refresh token on every exchange, so a refresh token can only be spent once. To stop two simultaneous requests from racing and invalidating each other, refreshes are serialised with a lock file at storage/.token.lock. Whichever request loses the race re-reads .env and adopts the token the winner already obtained instead of spending its own.

If the refresh itself fails, the reason is recorded and the buyer sees a 503 telling them to contact the seller, rather than a message blaming their input. Run php auth/setup.php again to reauthorize; this normally means the refresh token expired or the app's access was revoked.

Credentials. .env holds your API key, shared secret, OAuth tokens, and signing secret. It is gitignored and blocked by .htaccess. Never commit it. If it leaks, revoke the app's credentials in the Etsy developer console and rotate TOKEN_SECRET. Keep it at mode 600: it must stay writable by PHP for token refresh, but nothing else should be able to read it. See .env must be writable.

Debug logging. With DEBUG=true, raw API requests and responses are written to storage/logs/debug.log, which can include order details. The directory is gitignored and denied over HTTP, but leave DEBUG=false in production and delete old logs.

Project structure

├── .env.example                    Environment variable template
├── .htaccess                       Rewrites and access rules
├── composer.json                   Dependencies
├── index.html                      Buyer-facing form
├── auth/
│   └── setup.php                   One-time CLI OAuth2 setup
├── config/
│   └── etsy.php                    Loads and validates environment variables
├── products/
│   └── map.example.php             Listing ID to file path mapping template
├── public/
│   └── download.php                Verification endpoint and file delivery
├── src/
│   ├── EtsyClient.php              Etsy v3 API client
│   ├── FileDelivery.php            Path-checked file streaming
│   ├── PurchaseVerifier.php        Order ID and Transaction ID verification
│   └── TokenManager.php            Signed expiring tokens
└── storage/
    ├── files/                      Product files
    └── logs/                       Debug output

TODO

Ideas for future versions:

  • Admin page to monitor download activity
  • Support for multiple files per listing
  • Webhook support for automatic purchase validation

License

MIT

About

A secure self-hosted file delivery for Etsy sellers.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages