openapi: 3.0.3 info: title: PortraitFlow Customer API version: "1.0" description: | Transfer employee data to PortraitFlow and fetch the finished portraits straight into your own systems – no manual export or batch download. **Typical integration** 1. `GET /projects` – find the project id. 2. `POST /projects/{id}/employees` – send your employees (single or up to 200 per call). Use your own personnel number as `mitarbeiternummer` to match records later. 3. PortraitFlow invites the employees and edits the photos (as in the customer portal). 4. Poll `GET /projects/{id}/employees?photo_ready=1` (for example hourly). Each entry carries `photo.standard_url` / `photo.transparent_url` – fetch them with your API key. Remember which ids you already imported. **Authentication** – send your API key in every request: `Authorization: Bearer pf_…`. Keys are created and revoked in the customer portal under *Konto → API-Zugang*. A key is shown only once at creation. **Availability** – the API is available to active contract customers only. **Limits** – 120 requests per minute per key; at most 200 employees per create call. Exceeding the limit returns `429` with a `Retry-After` header. **Invitations** – employees created through the API start as `pending`. PortraitFlow sends the invitations, as it does for portal-created employees. contact: name: PortraitFlow email: studio@portraitflow.ch servers: - url: https://portraitflow.ch/api/v1 security: - bearerAuth: [] tags: - name: Projects - name: Employees - name: Photos paths: /projects: get: tags: [Projects] summary: List your projects responses: "200": description: All projects of the customer (including archived ones). content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/Project" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /projects/{projectId}/employees: parameters: - $ref: "#/components/parameters/ProjectId" get: tags: [Employees] summary: List the employees of a project description: Paginated, ordered by id. parameters: - name: status in: query schema: { type: string, enum: [pending, uploaded, edited, retake] } - name: mitarbeiternummer in: query description: Exact match on the employee number. schema: { type: string } - name: photo_ready in: query description: "`1` = only employees whose edited photo is finished and approved by the employee." schema: { type: boolean } - name: per_page in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: page in: query schema: { type: integer, minimum: 1, default: 1 } responses: "200": description: A page of employees. content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/Employee" } links: { type: object } meta: { type: object } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/ValidationError" } post: tags: [Employees] summary: Create one or several employees description: | Send one employee directly in the body, or several as `{"employees": [...]}` (max. 200). The call is all-or-nothing: if one entry is invalid, none is created and the errors are keyed like `employees.1.email`. `mitarbeiternummer` is optional; if omitted, PortraitFlow assigns `MA--NNN`. Email and employee number are unique per project. requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/EmployeeInput" - type: object required: [employees] properties: employees: type: array minItems: 1 maxItems: 200 items: { $ref: "#/components/schemas/EmployeeInput" } examples: single: value: { first_name: Anna, last_name: Muster, email: anna.muster@example.com, mitarbeiternummer: PERS-1042, locale: de } batch: value: employees: - { first_name: Anna, last_name: Muster, email: anna.muster@example.com, mitarbeiternummer: PERS-1042 } - { first_name: Ben, last_name: Beispiel, email: ben.beispiel@example.com } responses: "201": description: Created. A single object for a single employee, an array for a batch (same order as sent). content: application/json: schema: type: object properties: data: oneOf: - $ref: "#/components/schemas/Employee" - type: array items: { $ref: "#/components/schemas/Employee" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Conflict while assigning numbers (parallel requests). Retry. "422": { $ref: "#/components/responses/ValidationError" } /employees/{employeeId}: parameters: - $ref: "#/components/parameters/EmployeeId" get: tags: [Employees] summary: Get one employee responses: "200": description: The employee. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Employee" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [Employees] summary: Update an employee description: | Send only the fields to change. If the email of an already invited employee who has not uploaded a photo yet changes, a new invitation is sent automatically (`meta.invitation_resent`). requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/EmployeeUpdate" } responses: "200": description: The updated employee. content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Employee" } meta: type: object properties: invitation_resent: { type: boolean } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Employees] summary: Delete an employee (and their photos) responses: "204": { description: Deleted. } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /employees/{employeeId}/photo: parameters: - $ref: "#/components/parameters/EmployeeId" get: tags: [Photos] summary: Download the finished portrait description: | Returns the image file. Only available once the edited photo exists **and** the employee has approved it. Without `size` you get the edited image in full size. With `size` you get one of the sizes stored in your account (see `GET /image-sizes`), cropped like in the customer portal. parameters: - name: variant in: query description: "`standard` = JPEG with background (default, always a real JPEG; transparent areas become white), `transparent` = cut-out PNG (always a real PNG, keeps its transparency). With `size`, both variants are cropped identically (same section, aligned on the eyes)." schema: { type: string, enum: [standard, transparent], default: standard } - name: size in: query description: Index of one of your image sizes (`GET /image-sizes`). schema: { type: integer, minimum: 0 } responses: "200": description: The image file. content: image/jpeg: { schema: { type: string, format: binary } } image/png: { schema: { type: string, format: binary } } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: "Employee unknown, no edited photo yet (`code: not_available`) or unknown size (`code: unknown_size`)." "409": description: "The employee has not approved the image yet (`code: not_approved`). Try again later." "422": { $ref: "#/components/responses/ValidationError" } /image-sizes: get: tags: [Photos] summary: List your configured download sizes responses: "200": description: The sizes with the `index` to use as `size` parameter. content: application/json: schema: type: object properties: data: type: array items: type: object properties: index: { type: integer, example: 0 } label: { type: string, example: Quadrat } width: { type: integer, example: 500 } height: { type: integer, example: 500 } "401": { $ref: "#/components/responses/Unauthorized" } components: securitySchemes: bearerAuth: type: http scheme: bearer description: API key from the customer portal (`pf_…`). parameters: ProjectId: name: projectId in: path required: true schema: { type: integer } EmployeeId: name: employeeId in: path required: true schema: { type: integer } responses: Unauthorized: description: Missing, invalid or revoked API key. Forbidden: description: API access is not enabled for this account. NotFound: description: Unknown id, or it belongs to another customer. ValidationError: description: Invalid input. content: application/json: schema: type: object properties: message: { type: string } errors: type: object additionalProperties: type: array items: { type: string } schemas: Project: type: object properties: id: { type: integer } projektnummer: { type: string } name: { type: string } status: { type: string, enum: [draft, active, completed, archived] } start_date: { type: string, format: date, nullable: true } deadline: { type: string, format: date } employees_count: { type: integer } EmployeeInput: type: object required: [first_name, last_name, email] properties: first_name: { type: string, maxLength: 100 } last_name: { type: string, maxLength: 100 } email: { type: string, format: email, maxLength: 255 } mitarbeiternummer: type: string maxLength: 100 description: Your own personnel number (optional, unique per project). locale: type: string enum: [de, en] nullable: true description: Language of invitations; defaults to your account language. EmployeeUpdate: type: object properties: first_name: { type: string, maxLength: 100 } last_name: { type: string, maxLength: 100 } email: { type: string, format: email, maxLength: 255 } mitarbeiternummer: { type: string, maxLength: 100 } locale: { type: string, enum: [de, en], nullable: true } Employee: type: object properties: id: { type: integer } project_id: { type: integer } mitarbeiternummer: { type: string } first_name: { type: string } last_name: { type: string } email: { type: string, format: email } locale: { type: string, nullable: true } status: type: string enum: [pending, uploaded, edited, retake] description: "`pending` = no photo yet, `uploaded` = photo received, `edited` = edited photo available, `retake` = new photo requested." invited_at: { type: string, format: date-time, nullable: true } uploaded_at: { type: string, format: date-time, nullable: true } photo: type: object properties: approved: { type: boolean, description: The employee approved the edited image. } edited_at: { type: string, format: date-time, nullable: true } standard_url: type: string format: uri nullable: true description: Set only when the edited JPEG is finished and approved. transparent_url: type: string format: uri nullable: true description: Set only when the cut-out PNG is finished and approved.