Square API Integration: OAuth, Catalog Sync, Orders, Webhooks
Square API integration guide: OAuth vs personal tokens, catalog and inventory sync, idempotent payments and signed webhooks, with runnable Python code.
Long Nguyen
Lập trình viên Fullstack · Kỹ sư AI · Nhà nghiên cứu
What a Square API integration actually covers
A Square API integration connects your own software (a web store, an ERP, a booking tool, a mobile app) to a seller's Square account over REST. Square applies a single version number to all of its APIs, so Payments, Orders, Catalog and Inventory move together, and most real integrations touch four or five of them at once. The table maps each job to the API that does it.
| Job | API | What to know first |
|---|---|---|
| Connect a seller's account | OAuth API | Returns an access token and a refresh token. Server apps use the code flow; mobile and single-page apps use PKCE. |
| Product catalog | Catalog API | The catalog feeds the Square Dashboard item library and the items shown in Square Point of Sale. |
| Stock levels | Inventory API | Quantities are tracked only on item variations, per location and per state. |
| Orders and fulfillment | Orders API | Records line items, calculates totals, tracks fulfillment, and updates inventory when orders complete or are refunded. |
| Take payments | Payments API | Charges payment methods collected with Square's Web Payments SDK or In-App Payments SDK. |
| React to changes | Webhooks | Signed notifications such as catalog.version.updated, inventory.count.updated and oauth.authorization.revoked. |
Before writing any code, settle one question: which system is the source of truth for products, prices and stock? Square's own catalog-sync guidance starts there, and every later decision (which direction you sync, whether you push counts or adjustments, how deletes propagate) follows from the answer. Skipping it is how integrations end up overwriting prices in both directions.
The sections below follow the order in which things tend to break in production: authentication, environments, sync, payments, webhooks, versioning.
OAuth or personal access token: what your Square integration needs
Square offers two ways to get an access token. A personal access token belongs to your own developer account, which is enough when you only integrate your own Square account. As soon as other sellers connect to your app you need OAuth: the seller approves specific permissions on a Square page and your app receives tokens scoped to that seller. Some endpoints stay personal-token-only even inside an OAuth app; Square's OAuth API documentation states that the Webhook Subscriptions API and Events API do not accept OAuth access tokens.
| Flow | Use it for | Refresh token | Client secret |
|---|---|---|---|
| Code flow | Server-side apps where you control the hosting | Reusable; stays valid until the seller revokes access | Required |
| PKCE flow | Mobile apps, single-page apps, native desktop apps | Single-use; expires after 90 days | Not used (a code_verifier replaces it) |
You pick one flow and stay on it end to end; the documentation is explicit that the two cannot be mixed. For most web integrations that means the code flow. The limits that catch people out:
- Authorization codes expire after 5 minutes and can be used once, so exchange the code inside the callback request itself rather than handing it to a queue.
- The redirect URL must be HTTPS. Plain HTTP on localhost is allowed only in the Sandbox.
- Access tokens expire after 30 days. You renew them with the refresh token.
- If you lose the refresh token, the seller has to repeat the full authorization flow.
- One code-flow refresh token can mint several access tokens, each with its own 30-day life and each individually revocable. That suits a seller who runs several stores on one eCommerce site.
- Square OAuth is not a login system. It does not support OpenID or other single sign-on protocols on top of OAuth.
The failure that reaches customers is token expiry, because it lands 30 days after onboarding, long after anyone is watching. Renew on a schedule rather than on the first 401: a daily job that refreshes every token expiring within the next week keeps a live checkout from being the thing that discovers a dead token.
import os
import requests
def refresh_access_token(refresh_token):
# Code flow (confidential client). Under PKCE you omit client_secret
# and must store the new single-use refresh token that comes back.
resp = requests.post(
'https://connect.squareup.com/oauth2/token',
json={
'client_id': os.environ['SQUARE_APP_ID'],
'client_secret': os.environ['SQUARE_APP_SECRET'],
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
},
headers={'Square-Version': os.environ['SQUARE_VERSION']},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return {
'access_token': data['access_token'],
'expires_at': data['expires_at'],
'refresh_token': data.get('refresh_token', refresh_token),
}
Also subscribe to oauth.authorization.revoked. It fires when a seller revokes every token they granted your app. When it arrives, stop syncing that merchant and delete their stored tokens, instead of letting the refresh job fail against that account every night.
Square Sandbox vs production: what changes
Sandbox and production are separate environments with separate credentials. Mixing them returns an AUTHENTICATION_ERROR with the UNAUTHORIZED code, which looks exactly like an invalid token. When a call fails with that error, check the environment before you check scopes.
| Production | Sandbox | |
|---|---|---|
| API base URL | https://connect.squareup.com/v2 |
https://connect.squareupsandbox.com/v2 |
| OAuth base URL | https://connect.squareup.com/oauth2 |
https://connect.squareupsandbox.com/oauth2 |
| Application ID and tokens | Production ID and production tokens | Sandbox ID and Sandbox tokens; not interchangeable |
| Signing in on the authorization page | Sign in directly | Not possible; open the Sandbox Dashboard for a test account in another tab first. The session=false parameter is not supported. |
| Redirect URL | HTTPS | HTTPS, or HTTP on localhost |
Because Sandbox OAuth is awkward to drive, test the authorization flow end to end once, then use Sandbox test accounts and their generated access tokens for everything else (catalog, orders, payments). Square supports generating those tokens for a test account with specific permissions, which is far faster than repeating the consent screen.
How to sync a Square catalog and inventory with your store
Send changes from Square to your system
Square's guide to synchronizing a catalog with an external platform describes two triggers: react to the catalog.version.updated webhook, or poll on a fixed interval. Either way you call SearchCatalogObjects with begin_time so Square returns only what changed. Three details decide whether this works:
- Cache the timestamp from the previous event, not the current one. The event's
catalog_version.updated_atmarks the moment the new version was created, so the changes that triggered it sit after the previous marker. - Changes arrive from everywhere: the Dashboard, Square Point of Sale, and any other integration the seller runs. Your logic must not assume your own app is the only writer.
- Never call
ListCatalogon a schedule or per event. The result set gets large and pulls your app into rate limiting.
Deletions are returned by later searches with is_deleted set to true, so ask for deleted objects and propagate them, or your store keeps selling items the seller removed.
import os
import requests
BASE = 'https://connect.squareup.com/v2'
def square_headers(access_token):
return {
'Authorization': f'Bearer {access_token}',
'Square-Version': os.environ['SQUARE_VERSION'],
'Content-Type': 'application/json',
}
def catalog_changes_since(access_token, begin_time):
cursor = None
while True:
body = {
'begin_time': begin_time,
'object_types': ['ITEM', 'ITEM_VARIATION'],
'include_deleted_objects': True,
}
if cursor:
body['cursor'] = cursor
resp = requests.post(f'{BASE}/catalog/search', json=body,
headers=square_headers(access_token), timeout=30)
resp.raise_for_status()
data = resp.json()
yield from data.get('objects', [])
cursor = data.get('cursor')
if not cursor:
break
def on_catalog_version_updated(event, state):
# state['last_sync'] holds the previous event's catalog_version.updated_at
new_marker = event['data']['object']['catalog_version']['updated_at']
for obj in catalog_changes_since(state['access_token'], state['last_sync']):
if obj.get('is_deleted'):
remove_from_store(obj['id']) # your function
else:
upsert_into_store(obj) # your function
state['last_sync'] = new_marker # advance only after success
Send changes from your system to Square
When your platform holds the master catalog, push changes with the upsert and delete endpoints, and group like operations into batches. BatchUpsertCatalogObjects accepts up to 10,000 objects and produces a single webhook notification instead of thousands, which also protects your own webhook handler. Store the external ID on the Square object as a custom attribute value and store the Square object ID on your side, so every later update starts from a lookup rather than a fuzzy match.
Two-way sync is the trap. Square's guidance calls out concurrency, merge and duplicate-deletion risks, and a pricing hazard: a POS price change that silently rewrites the online price, when the seller may want different prices per channel. If you must go two-way, define per field which side wins.
Inventory is a set of states, not one number
Inventory tracks quantity moving between states such as IN_STOCK, SOLD and WASTE at a location, rather than decrementing a single counter. Only item variations can be tracked, and the API does not support tracking subcomponents, ingredients or bundles, so a kit or recipe item needs its own logic in your system. Reading needs the INVENTORY_READ permission and writing needs INVENTORY_WRITE.
| Object | What it holds | Who sets it |
|---|---|---|
| InventoryCount | Calculated quantity of a variation at one location in one state | Square, recalculated on every adjustment or physical count |
| InventoryAdjustment | A quantity moving from one state to another | Sales, receiving stock, waste, or your app |
| InventoryPhysicalCount | A verified quantity from a manual count or a trusted system | You |
| InventoryTransfer | Retired at Square version . A move between locations is now an adjustment whose from_location_id and to_location_id differ. |
Migrate to adjustments |
The practical consequence: a physical count overrides Square's calculation. If your system reads a quantity, spends a few seconds processing, then writes it back as a physical count, any in-store sale made in between is erased. A safe rule of thumb is to push adjustments (deltas) while Square Point of Sale is still selling, and reserve physical counts for a scheduled reconciliation or for a system that truly owns the master count. Subscribe to inventory.count.updated to hear about quantity changes as they happen.
Orders, payments and idempotency keys
The clean flow is to create an order in the Orders API, then pay it with CreatePayment referencing that order. Orders can be built from catalog items or from ad hoc line items defined at creation, and you can search them later, including by customer. When an order is completed or refunded, Square updates the inventory for its items, so if you also write inventory adjustments for the same sale you double-count it. Decide which side owns that decrement and stick to it.
Networks fail after the charge succeeds, which is exactly when a naive retry charges the customer twice. Square handles this with idempotency keys: repeat a request with the same key and the same body and you get the original response back instead of a second charge. Reuse the key with a changed body (a different amount, say) and you get an error, although Square notes the behavior can vary by API.
The mistake is generating a fresh random UUID inside the retry loop, which defeats the protection entirely. Derive the key from your own identifiers instead, and change it only when the customer deliberately starts a new payment attempt, for example with a different card.
import os
import uuid
import requests
NAMESPACE = uuid.UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8') # any fixed UUID
def charge_order(access_token, order_id, location_id, source_token,
amount_cents, attempt=1, currency='USD'):
# Same order + same attempt = same key, so network retries cannot double-charge.
key = str(uuid.uuid5(NAMESPACE, f'payment:{order_id}:{attempt}'))
body = {
'idempotency_key': key,
'source_id': source_token,
'order_id': order_id,
'location_id': location_id,
'amount_money': {'amount': amount_cents, 'currency': currency},
}
resp = requests.post(
'https://connect.squareup.com/v2/payments',
json=body,
headers={
'Authorization': f'Bearer {access_token}',
'Square-Version': os.environ['SQUARE_VERSION'],
'Content-Type': 'application/json',
},
timeout=30,
)
resp.raise_for_status()
return resp.json()['payment']
One commercial detail belongs in the design phase. As of , Square's Orders API documentation says there is no transaction fee for orders paid with Square payments, and a 1% fee per transaction if you use the Orders API with a non-Square payments provider. Confirm current terms with Square before you build a flow that depends on that split.
How to verify Square webhooks and handle duplicates
Your notification URL is public, so anyone can post to it. Every Square notification carries an x-square-hmacsha256-signature header, and you must recompute it and discard anything that does not match.
| Concern | What Square gives you |
|---|---|
| Authenticity | HMAC-SHA-256 signature built from the subscription's signature key, the notification URL and the raw request body |
| Comparison | Use a constant-time comparison; Square warns that a comparison that stops at the first difference is open to timing attacks, and that constant-time support varies by SDK helper |
| Duplicates | An event_id in the body, provided as an idempotency value; skip events you have already processed |
| Endpoint health | If your endpoint returns no 2xx for three weeks, Square sends warning emails after weeks one, two and three, then disables the subscription |
Because the signature covers the notification URL and the raw body, two failures cause most false rejections. The first is a framework that parses the JSON and a handler that re-serializes it, which changes the bytes. The second is a proxy or CDN that alters the scheme or host, so the URL your code reconstructs from the request no longer matches what you registered. Read the raw body, and take the notification URL from configuration rather than rebuilding it from the incoming request.
import json
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from square.utils.webhooks_helper import verify_signature
@csrf_exempt
@require_POST
def square_webhook(request):
body = request.body.decode('utf-8') # raw body, never re-serialized JSON
signature = request.headers.get('x-square-hmacsha256-signature', '')
if not verify_signature(
request_body=body,
signature_header=signature,
signature_key=settings.SQUARE_WEBHOOK_SIGNATURE_KEY,
notification_url=settings.SQUARE_WEBHOOK_URL, # exactly as registered
):
return HttpResponseForbidden()
event = json.loads(body)
# cache.add returns False when the key exists, i.e. we have seen this event
if not cache.add('square:event:' + event['event_id'], 1, timeout=7 * 24 * 3600):
return HttpResponse(status=200)
process_square_event.delay(event) # your task queue; return fast
return HttpResponse(status=200)
Acknowledge first and process later: verify, dedupe, enqueue, return 200. In production replace the cache check with a unique constraint on event_id in your database, so deduplication survives a cache flush.
Pin your Square API version and plan upgrades
Square versions its API with release dates in YYYY-MM-DD form, typically monthly, and a single version applies across every API. Each app registered in the Developer Console has a default version pinned to it, visible on the Credentials page, and that default is used for every request unless you override it with the Square-Version header. The response always echoes the version it used, so log it.
| Change Square makes | Breaking? |
|---|---|
| New endpoint | No |
| New optional or read-only field | No |
| Deprecation | No, but versioned so you can spot it |
| New required field | Yes |
| Retirement | Yes |
| Rename or reshape of a field, type or string constant | Yes |
| Different HTTP status code for an operation | Yes |
The InventoryTransfer retirement at version is a concrete example: code pinned to an older version keeps working, and the same code breaks the day you move past that date. So send Square-Version explicitly from your own configuration instead of trusting the console default, and upgrade deliberately. An upgrade includes every change since your previous version, so read the release notes for each version in between and run your test suite against Sandbox with the new header before touching production. Also note that the Java, Ruby, Python and PHP SDKs put the API date at the end of their version number, while the .NET, Node.js and Go SDKs do not, so check which API version a given SDK release targets.
Build a custom Square integration or use a ready-made connector
A ready-made connector is the right call when your catalog is small, one system clearly owns it, and you only need products, orders and payments to flow one way. Custom work starts to pay off when the two catalogs must stay consistent across more than one sales channel, when inventory has rules Square's model does not express (bundles, kits, reserved stock), or when order and customer data has to land in an ERP or CRM with your own business logic on top.
If you are connecting Square to a Shopify, WooCommerce, BigCommerce or custom store and the risks above (two-way price changes, inventory overwrites, token expiry) apply to you, that is the platform and API integration work covered by Netalith's eCommerce platform and API integration service.
Square API integration checklist before you go live
- Keep Sandbox and production credentials, base URLs and tokens in separate configuration, never in code.
- Run a scheduled token refresh for every connected seller, store refresh tokens encrypted, and handle
oauth.authorization.revoked. - Send
Square-Versionexplicitly and log the version returned on each response. - Derive idempotency keys from your own order and attempt identifiers, never from a fresh random value per retry.
- Verify the webhook signature against the raw body, deduplicate on
event_id, and return 200 before doing slow work. - Store external IDs on Square objects and Square IDs on your side; write in batches; never poll with
ListCatalog. - Choose deliberately between inventory adjustments and physical counts, and schedule a reconciliation job.
- Use Square's API logs and webhook event logs from the Developer Console when a call or delivery misbehaves.
If you would rather have this built and tested against your own catalog and sales channels, describe the scope through Netalith's free quote form.
CÂU HỎI THƯỜNG GẶP
Câu hỏi thường gặp
Do I need OAuth for a Square API integration, or is a personal access token enough?
A personal access token is enough when you only integrate your own Square account. If other sellers will connect to your app, you need OAuth so each seller grants specific permissions and your app receives tokens scoped to that seller. Some endpoints, such as the Webhook Subscriptions API and Events API, accept only a personal access token even in an OAuth app.
How long do Square OAuth access tokens last?
Square OAuth access tokens expire after 30 days. You renew them with the refresh token. In the code flow the refresh token does not expire until the seller revokes access; in the PKCE flow it is single-use and expires after 90 days. Refresh on a schedule before expiry rather than waiting for a failed call.
How do I verify a Square webhook?
Compute an HMAC-SHA-256 signature from your subscription signature key, the notification URL and the raw request body, then compare it in constant time with the x-square-hmacsha256-signature header. Reject anything that does not match. The Square SDKs include a helper for this, and you should also skip repeated events by checking the event_id in the body.
How do I keep Square inventory in sync with my online store?
Decide which system owns the master count first. Listen to inventory.count.updated for changes coming from Square, and push changes back as inventory adjustments while Square Point of Sale is still selling. Use physical counts only for scheduled reconciliation or when your system truly owns the count, because a physical count overrides Square's calculated quantity.
Does the Square Orders API cost extra?
As of September 29, 2026, Square's documentation says there is no transaction fee for orders paid with Square payments and a 1% fee per transaction when you use the Orders API with a non-Square payments provider. Terms can change, so confirm the current fee with Square before designing around it.