To call the SellerMagnet API from a Zap without leaving the X-Api-Key header sitting in a Zap step's own plaintext fields, set the key once in Storage by Zapier instead of typing it into a Webhooks by Zapier action. A Code by Zapier Python step then fetches the key at run time with StoreClient, applies the billing-aware retry policy the rest of this series uses, and dedupes against a real Zapier-specific trap: "Replay entire Zap run" re-bills a call that already succeeded.
Key Takeaways
- Storage by Zapier's Set Value/Get Value actions hold the API key outside any single Zap step; a Code step fetches it at run time with StoreClient instead of it sitting in a Webhooks by Zapier action's plaintext fields.
- Code by Zapier's Python steps have no Zapier SDK access and cannot read another action's stored connection credential, so an API by Zapier connection cannot feed a Code step either - Storage by Zapier is the mechanism built for this.
- A normal replay (manual or Autoreplay) only re-attempts steps that stopped on an errored status; "Replay entire Zap run" re-executes every step, successful ones included, and bills them again.
- A StoreClient dedup keyed on a stable id already present in the trigger data (not a value generated inside the Zap) survives a full-run replay and blocks the re-bill.
- Billing mirrors the rest of the series: free 429 (Retry-After)/401/403/gate-400, a 500 is refunded (the client still retries it once), 502/503 billed-capped-at-4, 404 billed-not-retried.
Where should the API key actually live in a Zap?
Zapier's own "Ways to make API requests in Zapier" (help.zapier.com, checked live) shows Webhooks by Zapier's Custom Request action supporting only Basic auth or none; a manually added X-Api-Key header there sits in that Zap step's own fields in plaintext, visible to anyone who opens or exports the Zap. API by Zapier avoids that by keeping an API key in its own connection - but that connection can only feed an API by Zapier action step, never a Code step, because Code by Zapier's Python steps have no Zapier SDK access (the SDK is JavaScript-only, per "Code by Zapier", help.zapier.com) and no way to read another connection's stored credential. Since the retry policy below needs a Code step, the right place for the key is Storage by Zapier: "Save and retrieve data from Zap workflows using Storage by Zapier" (help.zapier.com) documents a "Set Value" action that stores a value under a key, and a "Get Secret" action that hands a Code step the store secret it needs to read that value back with StoreClient - the same utility this guide already uses for the replay dedup.
| Setup | How it is entered | Where it lives afterwards |
|---|---|---|
| Webhooks by Zapier (Custom Request) | Type the header into the step's own fields | Stays in that Zap step forever, in plaintext, visible to anyone who opens or exports the Zap |
| Storage by Zapier + a Code step | Set the value once with a temporary Set Value action, then delete that action | Lives only in your Storage by Zapier account; later steps fetch it at runtime with StoreClient.get(), it is never shown in any Zap step again |
- Create a Storage by Zapier accountAdd any Storage by Zapier action, connect a new account, and enter a UUID4 as the store secret (this is the value the Code step below will need, not the SellerMagnet key itself).
- Set the key onceAdd a temporary Storage by Zapier step, event Set Value, Key sellermagnet_api_key, Value your real key. Test it once.
- Delete that stepRemove the Set Value action from the Zap. The value stays in your Storage by Zapier account; nothing in the Zap's own steps shows it again.
- Read it back at run timeIn the Code by Zapier step below, StoreClient(store_secret).get("sellermagnet_api_key") fetches it fresh on every run.
Why does the retry logic need its own Code step?
A plain API Request action returns whatever SellerMagnet sends and stops there: no branching on the status code, no backoff, no memory of a previous run. The series' billing-aware retry - free 401/403/429 (honour Retry-After), a refunded 500 the client still retries once, 502/503 billed on every attempt and capped at 4, a billed 404 not retried at all - has to live somewhere that can branch and loop, which inside a Zap means a Code by Zapier Python step. "Using Code by Zapier" and "Use Python code in Zap workflows" (help.zapier.com, checked live) confirm this Python environment already provides requests and a StoreClient get/set utility with no ability to import your own local files, so the whole policy has to be one script, the same constraint the AWS Lambda and Cloudflare Worker posts in this series hit for their own platform reasons.
sellermagnet_zapier.py - paste into a Code by Zapier Python step (Run Python)
"""sellermagnet_zapier.py - paste into a Code by Zapier Python step (event: Run Python).
Code by Zapier pre-injects `requests` and `StoreClient`; there is no way to import a local
module, so the whole billing-aware retry and the replay-safe dedup live in this one script.
Input Data: asin, request_id (a stable id from the trigger, e.g. a sheet row id - never a
value generated inside the Zap), store_secret (the UUID4 from your Storage by Zapier account,
used both for the dedup store and to fetch the API key set there with a one-time Set Value
action - see "Store the API key once, outside the Zap's own steps" above).
"""
import re
import time
API = "https://sellermagnet-api.com/api/amazon-product-lookup"
MARKETPLACE_ID = "ATVPDKIKX0DER" # amazon.com
RETRYABLE = {429, 500, 502, 503}
ASIN_RE = re.compile(r"^[A-Z0-9]{10}$")
def classify_billed(status: int) -> bool:
"""True if a response at this status is charged, from the status-codes/billing table.
A gate 400, 401/403/429 and 500 are free; 200/404/502/503 are billed."""
if status in (400, 401, 403, 429, 500):
return False
return True
def call_with_retry(asin: str, api_key: str, attempts: int = 4) -> dict:
internal_errors = 0
credits_spent = 0
for attempt in range(1, attempts + 1):
resp = requests.get(API, params={"asin": asin, "marketplaceId": MARKETPLACE_ID},
headers={"X-Api-Key": api_key}, timeout=120)
try:
body = resp.json()
except ValueError:
body = {}
if classify_billed(resp.status_code):
credits_spent += 1
if resp.status_code == 200 and body.get("success"):
return {"status": "ok", "data": body["data"], "attempts": attempt, "credits_spent": credits_spent}
if resp.status_code == 500:
internal_errors += 1
if resp.status_code not in RETRYABLE or attempt == attempts or internal_errors > 1:
return {"status": "error", "http_status": resp.status_code,
"message": body.get("message", "request failed"), "attempts": attempt,
"credits_spent": credits_spent}
try:
wait = float(resp.headers.get("Retry-After", 0) or 0)
except ValueError:
wait = 0.0
time.sleep(max(min(2 ** attempt, 30), wait))
return {"status": "error", "http_status": None, "message": "exhausted", "attempts": attempts,
"credits_spent": credits_spent}
def run(input_data):
store = StoreClient(input_data["store_secret"])
asin = (input_data.get("asin") or "").strip().upper()
api_key = store.get("sellermagnet_api_key") # set once via a Storage by Zapier action
request_id = input_data.get("request_id", "") # stable upstream id - NOT generated inside the Zap
if not ASIN_RE.match(asin):
return {"skipped": True, "reason": "malformed_asin", "credits_spent": 0}
dedup_key = f"billed:{request_id}:{asin}"
if request_id and store.get(dedup_key):
return {"skipped": True, "reason": "already_billed_this_request_id", "credits_spent": 0}
result = call_with_retry(asin, api_key)
if request_id and result["credits_spent"] > 0:
store.set(dedup_key, True)
return result
if __name__ == "__main__":
# Local test only: StoreClient/requests are injected globals inside the real Code by Zapier
# editor; this is not how you paste it there (see the one-liner callout below for that).
print(run({"asin": "B0CL61F39H", "request_id": "row1",
"store_secret": "a-uuid4-you-generate-once"}))
Zapier's own editor expects the LAST line of a Run Python step to return its output, not a function definition at the top level; the file above is written and tested as an importable module so every function can be unit-tested on its own, but what actually goes in the Zapier code box is everything above unchanged, with its if __name__ block dropped and this one line in its place.
Last line to paste in the Zapier editor instead of the if __name__ block above
return run(input_data)
Illustrative run output (same script, four calls: fresh, a replay of the first, a different request_id, a malformed ASIN)
--- call 1: fresh request_id="row1" ---
{'status': 'ok', 'attempts': 1, 'credits_spent': 1, 'data': {...}}
--- call 2: "Replay entire Zap run" reuses request_id="row1" ---
{'skipped': True, 'reason': 'already_billed_this_request_id', 'credits_spent': 0}
--- call 3: same ASIN, different request_id="row2" ---
{'status': 'ok', 'attempts': 1, 'credits_spent': 1, 'data': {...}}
--- call 4: malformed asin="short" ---
{'skipped': True, 'reason': 'malformed_asin', 'credits_spent': 0}
billed SellerMagnet calls: 2 (the replay and the malformed ASIN cost nothing)

How does the StoreClient dedup actually block a re-bill?
run() builds a dedup key from request_id and the ASIN together, where request_id has to already exist in the trigger data - a spreadsheet row's own id, a form submission id, anything the upstream app assigned before the Zap ever ran. After a billed call, the key is written to the store. On a normal new trigger event, request_id is new, so the store has nothing under that key and the call goes through. On a "Replay entire Zap run" of the SAME event, request_id is identical (the replay reuses the original trigger data), the store already holds it, and the step returns {'skipped': True, ...} without calling SellerMagnet at all - no second credit spent for the same event. A value generated inside the Zap itself (the run's own timestamp, a freshly made UUID) would NOT work here, because it differs between the original run and its replay and the dedup would never fire.
| Status | Meaning | Charged | Code step action |
|---|---|---|---|
200 | success | Yes | Use data, store the request_id/asin key |
400 (bad asin) | rejected before any call | No | Fix the input |
400 (gate) | marketplace not covered | No | Fix the call |
401 / 403 | key or credits | No | Stop |
404 | not on this marketplace | Yes | Store as missing |
429 | concurrency cap | No | Wait retry-after |
500 | internal error | No - refunded | Retry once |
502 / 503 | upstream / breaker | Yes | Wait, then retry, capped at 4 |
The 400 (bad asin) row is a client check, not an API one
/amazon-product-lookup does not validate ASIN format server-side at all; a malformed ASIN sent through would be billed like any other call. The regex in run() is what keeps a typo from ever reaching SellerMagnet, not a free 400 the API itself returns.

request_id has to come from the trigger, not the Zap
Map request_id to a field the trigger app already assigned - a spreadsheet row id, a form response id, a database primary key. A value you generate with a Formatter step inside the Zap (a new timestamp or UUID) is created fresh on every run, including a full-run replay, so it defeats the dedup entirely.
Frequently Asked Questions
Why not pass the API key straight into the Code step as Input Data?
That is functionally the same plaintext problem as Webhooks by Zapier: whatever you map into a Code step's Input Data is visible in that step's own configuration. Storage by Zapier's Set Value/Get Value actions (help.zapier.com) exist precisely to hold a value like this outside any single step, fetched only at run time.
Can't a Code step read a connection's credential directly, the way API by Zapier does?
No. Code by Zapier's Python steps have no access to the Zapier SDK (JavaScript-only per "Code by Zapier", help.zapier.com) and no way to read another action's stored connection credential. If you want API by Zapier's own connection-based auth, the HTTP call itself has to be an API by Zapier action step, not a Code step - and that path loses the branching this guide's retry policy needs.
Does replaying a Zap run from Zap History re-bill a successful SellerMagnet call?
Not a normal replay: Zapier only re-attempts steps that stopped on an errored status, so an already-successful API call step is skipped. "Replay entire Zap run", a separate option in the Zap editor, re-executes every step including successful ones and bills them again - the StoreClient dedup below exists specifically for that second case.
Why key the dedup on a request_id from the trigger, not something generated in the Zap?
"Replay entire Zap run" reuses the SAME trigger data to start a new run, so a value generated inside the Zap (a timestamp, a random id) would differ between the original run and the replay and the dedup would never catch it. A value that already existed in the trigger data - a sheet row's own id, a form submission id - stays identical across the replay, which is what makes it work as a dedup key.
What happens if I don't pass a request_id at all?
The Code step still works; it just never deduplicates, so every run (including a full Zap replay) bills again. That is fine for a one-off manual test, not for a scheduled or triggered Zap that might get replayed.
Does Code by Zapier need an import requests line?
No. requests and the StoreClient utility are already available inside a Code by Zapier Python step; adding the import line yourself is harmless but unnecessary.
Bottom line: set the API key once in Storage by Zapier instead of a Webhooks by Zapier step's plaintext fields, and put the retry and dedup logic in one Code by Zapier Python step that fetches the key with StoreClient and keys the dedup on a stable id from the trigger. The same billing-aware retry policy, in languages that run outside Zapier, is in the Python quickstart and the rate limits and retries guide; a free account includes 150 credits to try it.