[{"data":1,"prerenderedAt":644},["ShallowReactive",2],{"navigation":3,"page:\u002Fauthentication":45},{"title":4,"links":5},"API reference",[6,9,12,15,18,21,24,27,30,33,36,39,42],{"title":7,"href":8},"Accounts","\u002Freference\u002Faccounts",{"title":10,"href":11},"Connect","\u002Freference\u002Fconnect",{"title":13,"href":14},"Calendars","\u002Freference\u002Fcalendars",{"title":16,"href":17},"Events","\u002Freference\u002Fevents",{"title":19,"href":20},"Availability","\u002Freference\u002Favailability",{"title":22,"href":23},"Meetings","\u002Freference\u002Fmeetings",{"title":25,"href":26},"Invitations","\u002Freference\u002Finvitations",{"title":28,"href":29},"Webhooks","\u002Freference\u002Fwebhooks",{"title":31,"href":32},"Metadata","\u002Freference\u002Fmetadata",{"title":34,"href":35},"Keys","\u002Freference\u002Fkeys",{"title":37,"href":38},"Sandbox","\u002Freference\u002Fsandbox",{"title":40,"href":41},"Usage","\u002Freference\u002Fusage",{"title":43,"href":44},"Logs","\u002Freference\u002Flogs",{"kind":46,"title":47,"description":48,"sections":49,"doc":62},"guide","Authentication","How to authenticate with live and test API keys, what each key has access to, how scopes work, and how to rotate a key without a failed request.",[50,53,56,59],{"id":51,"title":52},"live-and-test-keys","Live and test keys",{"id":54,"title":55},"scopes","Scopes",{"id":57,"title":58},"rotating-a-key","Rotating a key",{"id":60,"title":61},"authentication-errors","Authentication errors",{"id":63,"title":47,"body":64,"description":48,"extension":635,"hero":636,"meta":637,"navigation":401,"path":638,"rawbody":639,"sections":640,"seo":641,"stem":70,"v1":642,"__hash__":643},"docs\u002Fauthentication.md",{"type":65,"value":66,"toc":629},"minimark",[67,71,88,124,138,146,164,167,242,245,247,287,293,295,298,313,480,523,529,531,625],[68,69,47],"h1",{"id":70},"authentication",[72,73,74],"lead",{},[75,76,77,78,82,83,87],"p",{},"Every request must include one of your app's API keys in the ",[79,80,81],"code",{},"x-api-key"," header. You create keys in the dashboard, under your app's ",[84,85,86],"strong",{},"API keys",".",[89,90,95],"pre",{"className":91,"code":92,"language":93,"meta":94,"style":94},"language-bash shiki shiki-themes css-variables","curl https:\u002F\u002Fapi.pyas.io\u002Fv2\u002Faccounts \\\n  -H \"x-api-key: pyas_sk_...\"\n","bash","",[79,96,97,114],{"__ignoreMap":94},[98,99,102,106,110],"span",{"class":100,"line":101},"line",1,[98,103,105],{"class":104},"s1IGz","curl",[98,107,109],{"class":108},"sTB4W"," https:\u002F\u002Fapi.pyas.io\u002Fv2\u002Faccounts",[98,111,113],{"class":112},"sqSWz"," \\\n",[98,115,117,120],{"class":100,"line":116},2,[98,118,119],{"class":108},"  -H",[98,121,123],{"class":122},"sk1Au"," \"x-api-key: pyas_sk_...\"\n",[75,125,126,127,129,130,133,134,137],{},"Send the key only in the ",[79,128,81],{}," header. If you send it in an ",[79,131,132],{},"Authorization: Bearer"," header instead, the API rejects the request with ",[79,135,136],{},"AUTH_INVALID_KEY",", and the error's hint explains which header to use.",[75,139,140,141,87],{},"Keep your API keys on your server. Code that runs in the browser never needs a key, even to connect accounts. See ",[142,143,145],"a",{"href":144},"\u002Fconnect","Connecting accounts",[75,147,148,149,152,153,156,157,159,160,163],{},"The API refuses a live key sent from a web page with ",[79,150,151],{},"AUTH_KEY_IN_BROWSER",", and emails your app's owner the first time it happens, because anyone who opens the page can read the key. A test key sent from a web page still works, but the response includes a ",[79,154,155],{},"Pyas-Warning"," header. Either way, the dashboard's ",[84,158,86],{}," page shows which key was sent from a web page, and from which site. Requests from a server don't include an ",[79,161,162],{},"Origin"," header, so they are never affected.",[165,166,52],"h2",{"id":51},[168,169,170,185],"table",{},[171,172,173],"thead",{},[174,175,176,179,182],"tr",{},[177,178],"th",{},[177,180,181],{},"Live key",[177,183,184],{},"Test key",[186,187,188,204,220,231],"tbody",{},[174,189,190,194,199],{},[191,192,193],"td",{},"Starts with",[191,195,196],{},[79,197,198],{},"pyas_sk_",[191,200,201],{},[79,202,203],{},"pyas_sk_test_",[174,205,206,209,212],{},[191,207,208],{},"Has access to",[191,210,211],{},"Your users' connected calendars",[191,213,214,215,219],{},"Your app's ",[142,216,218],{"href":217},"\u002Fsandbox","sandbox"," only",[174,221,222,225,228],{},[191,223,224],{},"Counts toward",[191,226,227],{},"Your plan's monthly requests",[191,229,230],{},"A separate monthly allowance for testing",[174,232,233,236,239],{},[191,234,235],{},"Can connect accounts",[191,237,238],{},"Yes",[191,240,241],{},"No. The sandbox has its own accounts.",[75,243,244],{},"Live and test keys are kept separate. A test key can't read a real calendar, and a live key can't access the sandbox. Code that you write with a test key works without changes when you switch to a live key.",[165,246,55],{"id":54},[75,248,249,250,253,254,257,258,260,261,264,265,268,269,271,272,274,275,278,279,282,283,286],{},"A key has the ",[79,251,252],{},"read"," scope, the ",[79,255,256],{},"write"," scope, or both. The ",[79,259,252],{}," scope allows ",[79,262,263],{},"GET"," requests, and the two ",[79,266,267],{},"POST"," requests that only read: free\u002Fbusy and meeting times (see ",[142,270,19],{"href":20},"). Every other request requires the ",[79,273,256],{}," scope, and so does reading a webhook's signing secret (",[79,276,277],{},"GET \u002Fv2\u002Fwebhooks\u002F{webhookId}\u002Fsecret","). If a key doesn't have the scope that a request requires, the API returns ",[79,280,281],{},"AUTH_INSUFFICIENT_SCOPE"," with a ",[79,284,285],{},"403 Forbidden"," status.",[75,288,289,290,292],{},"For example, you can give a reporting job a read-only key and grant ",[79,291,256],{}," only to the services that write to calendars.",[165,294,58],{"id":57},[75,296,297],{},"When you rotate a key, the API creates a new key and keeps the old one working for a grace period. This lets you deploy the new key without any failed requests.",[75,299,300,301,304,305,308,309,312],{},"The response includes the new key's value in ",[79,302,303],{},"key",", the new key's details in ",[79,306,307],{},"newKey",", and the old key's details in ",[79,310,311],{},"previousKey",". The old key's value isn't returned: it's the key you sent the request with.",[314,315,316,433],"code-group",{},[89,317,322],{"className":318,"code":319,"filename":320,"language":321,"meta":94,"style":94},"language-js shiki shiki-themes css-variables","\u002F\u002F Send this request with the key you want to replace.\nconst { key, newKey, previousKey } = await pyas.keys.rotate({ keyId: 'current', graceHours: 24 })\n\n\u002F\u002F key: the NEW key's value, such as 'pyas_sk_live_...'. Store it now, in place of the old\n\u002F\u002F key: it's returned only in this response.\n\u002F\u002F newKey: the new key's details (keyId, last4, scopes). Its value is key, above.\n\u002F\u002F previousKey: the key you just rotated. It keeps working until previousKey.graceEndsAt,\n\u002F\u002F 24 hours from now, so you have time to deploy the new key.\n","Node.js","js",[79,323,324,330,396,403,409,415,421,427],{"__ignoreMap":94},[98,325,326],{"class":100,"line":101},[98,327,329],{"class":328},"ssgCD","\u002F\u002F Send this request with the key you want to replace.\n",[98,331,332,336,339,342,346,349,351,354,357,360,363,366,368,371,374,377,380,383,385,388,390,393],{"class":100,"line":116},[98,333,335],{"class":334},"sag7P","const",[98,337,338],{"class":112}," { ",[98,340,303],{"class":341},"sXULy",[98,343,345],{"class":344},"sEEt9",",",[98,347,348],{"class":341}," newKey",[98,350,345],{"class":344},[98,352,353],{"class":341}," previousKey",[98,355,356],{"class":112}," } ",[98,358,359],{"class":334},"=",[98,361,362],{"class":334}," await",[98,364,365],{"class":341}," pyas",[98,367,87],{"class":104},[98,369,370],{"class":341},"keys",[98,372,373],{"class":104},".rotate",[98,375,376],{"class":112},"({ keyId",[98,378,379],{"class":334},":",[98,381,382],{"class":122}," 'current'",[98,384,345],{"class":344},[98,386,387],{"class":112}," graceHours",[98,389,379],{"class":334},[98,391,392],{"class":341}," 24",[98,394,395],{"class":112}," })\n",[98,397,399],{"class":100,"line":398},3,[98,400,402],{"emptyLinePlaceholder":401},true,"\n",[98,404,406],{"class":100,"line":405},4,[98,407,408],{"class":328},"\u002F\u002F key: the NEW key's value, such as 'pyas_sk_live_...'. Store it now, in place of the old\n",[98,410,412],{"class":100,"line":411},5,[98,413,414],{"class":328},"\u002F\u002F key: it's returned only in this response.\n",[98,416,418],{"class":100,"line":417},6,[98,419,420],{"class":328},"\u002F\u002F newKey: the new key's details (keyId, last4, scopes). Its value is key, above.\n",[98,422,424],{"class":100,"line":423},7,[98,425,426],{"class":328},"\u002F\u002F previousKey: the key you just rotated. It keeps working until previousKey.graceEndsAt,\n",[98,428,430],{"class":100,"line":429},8,[98,431,432],{"class":328},"\u002F\u002F 24 hours from now, so you have time to deploy the new key.\n",[89,434,437],{"className":91,"code":435,"filename":436,"language":93,"meta":94,"style":94},"curl -X POST https:\u002F\u002Fapi.pyas.io\u002Fv2\u002Fkeys\u002Fcurrent\u002Frotate \\\n  -H \"x-api-key: $PYAS_API_KEY\" \\\n  -H \"content-type: application\u002Fjson\" \\\n  -d '{ \"graceHours\": 24 }'\n","cURL",[79,438,439,454,463,472],{"__ignoreMap":94},[98,440,441,443,446,449,452],{"class":100,"line":101},[98,442,105],{"class":104},[98,444,445],{"class":108}," -X",[98,447,448],{"class":108}," POST",[98,450,451],{"class":108}," https:\u002F\u002Fapi.pyas.io\u002Fv2\u002Fkeys\u002Fcurrent\u002Frotate",[98,453,113],{"class":112},[98,455,456,458,461],{"class":100,"line":116},[98,457,119],{"class":108},[98,459,460],{"class":122}," \"x-api-key: $PYAS_API_KEY\"",[98,462,113],{"class":112},[98,464,465,467,470],{"class":100,"line":398},[98,466,119],{"class":108},[98,468,469],{"class":122}," \"content-type: application\u002Fjson\"",[98,471,113],{"class":112},[98,473,474,477],{"class":100,"line":405},[98,475,476],{"class":108},"  -d",[98,478,479],{"class":122}," '{ \"graceHours\": 24 }'\n",[481,482,483,490,497,504,520],"ul",{},[484,485,486,489],"li",{},[79,487,488],{},"current"," refers to the key that you send the request with. A key can rotate only itself, so to rotate a different key, send the request with that key or use the dashboard.",[484,491,492,493,496],{},"The grace period is 24 hours by default, with a maximum of 168 hours. A value of ",[79,494,495],{},"0"," makes the old key stop working immediately.",[484,498,499,500,503],{},"During the grace period, every response to a request made with the old key includes a ",[79,501,502],{},"Pyas-Key-Expires-At"," header. You can search your logs for this header to find any service you forgot to update.",[484,505,506,507,510,511,515,516,519],{},"Pyas sends an ",[79,508,509],{},"api_key.expiring"," ",[142,512,514],{"href":513},"\u002Fwebhooks","webhook"," event 1 hour before the old key stops working, and an ",[79,517,518],{},"api_key.revoked"," event when it stops working.",[484,521,522],{},"You can rotate a key only once. The next time you rotate, rotate the new key.",[75,524,525,526,528],{},"If a key has an expiration date and you don't rotate it, you receive an ",[79,527,509],{}," event and an email 7 days before it expires.",[165,530,61],{"id":60},[168,532,533,546],{},[171,534,535],{},[174,536,537,540,543],{},[177,538,539],{},"Code",[177,541,542],{},"Status",[177,544,545],{},"Meaning",[186,547,548,566,581,596,610],{},[174,549,550,557,560],{},[191,551,552],{},[142,553,555],{"href":554},"\u002Ferrors\u002FAUTH_INVALID_KEY",[79,556,136],{},[191,558,559],{},"401",[191,561,562,563,565],{},"The key is missing, unknown or revoked, was sent somewhere other than the ",[79,564,81],{}," header, or was replaced by a rotation whose grace period has ended",[174,567,568,576,578],{},[191,569,570],{},[142,571,573],{"href":572},"\u002Ferrors\u002FAUTH_EXPIRED_KEY",[79,574,575],{},"AUTH_EXPIRED_KEY",[191,577,559],{},[191,579,580],{},"The key has passed the expiry date it was created with",[174,582,583,590,593],{},[191,584,585],{},[142,586,588],{"href":587},"\u002Ferrors\u002FAUTH_INSUFFICIENT_SCOPE",[79,589,281],{},[191,591,592],{},"403",[191,594,595],{},"The key doesn't have the scope that this request requires",[174,597,598,605,607],{},[191,599,600],{},[142,601,603],{"href":602},"\u002Ferrors\u002FAUTH_KEY_IN_BROWSER",[79,604,151],{},[191,606,592],{},[191,608,609],{},"A live key was sent from a web page. Make the request from your server, and rotate the key",[174,611,612,620,622],{},[191,613,614],{},[142,615,617],{"href":616},"\u002Ferrors\u002FAUTH_SANDBOX_MISMATCH",[79,618,619],{},"AUTH_SANDBOX_MISMATCH",[191,621,592],{},[191,623,624],{},"A test key was used for an operation that only a live key can perform, or the reverse",[626,627,628],"style",{},"html pre.shiki code .s1IGz, html code.shiki .s1IGz{--shiki-default:var(--shiki-token-function)}html pre.shiki code .sTB4W, html code.shiki .sTB4W{--shiki-default:var(--shiki-token-string)}html pre.shiki code .sqSWz, html code.shiki .sqSWz{--shiki-default:var(--shiki-foreground)}html pre.shiki code .sk1Au, html code.shiki .sk1Au{--shiki-default:var(--shiki-token-string-expression)}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .ssgCD, html code.shiki .ssgCD{--shiki-default:var(--shiki-token-comment)}html pre.shiki code .sag7P, html code.shiki .sag7P{--shiki-default:var(--shiki-token-keyword)}html pre.shiki code .sXULy, html code.shiki .sXULy{--shiki-default:var(--shiki-token-constant)}html pre.shiki code .sEEt9, html code.shiki .sEEt9{--shiki-default:var(--shiki-token-punctuation)}",{"title":94,"searchDepth":398,"depth":398,"links":630},[631,632,633,634],{"id":51,"depth":116,"text":52},{"id":54,"depth":116,"text":55},{"id":57,"depth":116,"text":58},{"id":60,"depth":116,"text":61},"md",false,{},"\u002Fauthentication","---\ntitle: Authentication\ndescription: How to authenticate with live and test API keys, what each key has access to, how scopes work, and how to rotate a key without a failed request.\nv1: \u002Frest-api-reference\u002Fapi-authentication\n---\n\n# Authentication\n\n::lead\nEvery request must include one of your app's API keys in the `x-api-key` header. You create keys in the dashboard, under your app's **API keys**.\n::\n\n```bash\ncurl https:\u002F\u002Fapi.pyas.io\u002Fv2\u002Faccounts \\\n  -H \"x-api-key: pyas_sk_...\"\n```\n\nSend the key only in the `x-api-key` header. If you send it in an `Authorization: Bearer` header instead, the API rejects the request with `AUTH_INVALID_KEY`, and the error's hint explains which header to use.\n\nKeep your API keys on your server. Code that runs in the browser never needs a key, even to connect accounts. See [Connecting accounts](\u002Fconnect).\n\nThe API refuses a live key sent from a web page with `AUTH_KEY_IN_BROWSER`, and emails your app's owner the first time it happens, because anyone who opens the page can read the key. A test key sent from a web page still works, but the response includes a `Pyas-Warning` header. Either way, the dashboard's **API keys** page shows which key was sent from a web page, and from which site. Requests from a server don't include an `Origin` header, so they are never affected.\n\n## Live and test keys\n\n| | Live key | Test key |\n|---|---|---|\n| Starts with | `pyas_sk_` | `pyas_sk_test_` |\n| Has access to | Your users' connected calendars | Your app's [sandbox](\u002Fsandbox) only |\n| Counts toward | Your plan's monthly requests | A separate monthly allowance for testing |\n| Can connect accounts | Yes | No. The sandbox has its own accounts. |\n\nLive and test keys are kept separate. A test key can't read a real calendar, and a live key can't access the sandbox. Code that you write with a test key works without changes when you switch to a live key.\n\n## Scopes\n\nA key has the `read` scope, the `write` scope, or both. The `read` scope allows `GET` requests, and the two `POST` requests that only read: free\u002Fbusy and meeting times (see [Availability](\u002Freference\u002Favailability)). Every other request requires the `write` scope, and so does reading a webhook's signing secret (`GET \u002Fv2\u002Fwebhooks\u002F{webhookId}\u002Fsecret`). If a key doesn't have the scope that a request requires, the API returns `AUTH_INSUFFICIENT_SCOPE` with a `403 Forbidden` status.\n\nFor example, you can give a reporting job a read-only key and grant `write` only to the services that write to calendars.\n\n## Rotating a key\n\nWhen you rotate a key, the API creates a new key and keeps the old one working for a grace period. This lets you deploy the new key without any failed requests.\n\nThe response includes the new key's value in `key`, the new key's details in `newKey`, and the old key's details in `previousKey`. The old key's value isn't returned: it's the key you sent the request with.\n\n::code-group\n```js [Node.js]\n\u002F\u002F Send this request with the key you want to replace.\nconst { key, newKey, previousKey } = await pyas.keys.rotate({ keyId: 'current', graceHours: 24 })\n\n\u002F\u002F key: the NEW key's value, such as 'pyas_sk_live_...'. Store it now, in place of the old\n\u002F\u002F key: it's returned only in this response.\n\u002F\u002F newKey: the new key's details (keyId, last4, scopes). Its value is key, above.\n\u002F\u002F previousKey: the key you just rotated. It keeps working until previousKey.graceEndsAt,\n\u002F\u002F 24 hours from now, so you have time to deploy the new key.\n```\n\n```bash [cURL]\ncurl -X POST https:\u002F\u002Fapi.pyas.io\u002Fv2\u002Fkeys\u002Fcurrent\u002Frotate \\\n  -H \"x-api-key: $PYAS_API_KEY\" \\\n  -H \"content-type: application\u002Fjson\" \\\n  -d '{ \"graceHours\": 24 }'\n```\n::\n\n- `current` refers to the key that you send the request with. A key can rotate only itself, so to rotate a different key, send the request with that key or use the dashboard.\n\n- The grace period is 24 hours by default, with a maximum of 168 hours. A value of `0` makes the old key stop working immediately.\n- During the grace period, every response to a request made with the old key includes a `Pyas-Key-Expires-At` header. You can search your logs for this header to find any service you forgot to update.\n- Pyas sends an `api_key.expiring` [webhook](\u002Fwebhooks) event 1 hour before the old key stops working, and an `api_key.revoked` event when it stops working.\n- You can rotate a key only once. The next time you rotate, rotate the new key.\n\nIf a key has an expiration date and you don't rotate it, you receive an `api_key.expiring` event and an email 7 days before it expires.\n\n## Authentication errors\n\n| Code | Status | Meaning |\n|---|---|---|\n| [`AUTH_INVALID_KEY`](\u002Ferrors\u002FAUTH_INVALID_KEY) | 401 | The key is missing, unknown or revoked, was sent somewhere other than the `x-api-key` header, or was replaced by a rotation whose grace period has ended |\n| [`AUTH_EXPIRED_KEY`](\u002Ferrors\u002FAUTH_EXPIRED_KEY) | 401 | The key has passed the expiry date it was created with |\n| [`AUTH_INSUFFICIENT_SCOPE`](\u002Ferrors\u002FAUTH_INSUFFICIENT_SCOPE) | 403 | The key doesn't have the scope that this request requires |\n| [`AUTH_KEY_IN_BROWSER`](\u002Ferrors\u002FAUTH_KEY_IN_BROWSER) | 403 | A live key was sent from a web page. Make the request from your server, and rotate the key |\n| [`AUTH_SANDBOX_MISMATCH`](\u002Ferrors\u002FAUTH_SANDBOX_MISMATCH) | 403 | A test key was used for an operation that only a live key can perform, or the reverse |\n",null,{"title":47,"description":48},"\u002Frest-api-reference\u002Fapi-authentication","0a4CpCTMeAXpogiTaZc-OsmC0N6Av3R054qHcPYW4i4",1794697270669]