Getting 403 forbidden for script to add items to item set

Hi All,

I’m working on a script that will add a list of items, collected by item id, to a given item set. Here’s the snippet (python)

  def add_items_to_itemset(self, item_ids: Set[int], itemset_id: int) -> None:
        """
        Add items to the specified item set.

        Args:
            item_ids: Set of item IDs to add to the item set
            itemset_id: ID of the item set to add items to
        """
        success_count = 0
        error_count = 0
        already_in_set = 0

        print(f"\nDEBUG: Starting to add {len(item_ids)} items to item set {itemset_id}")

        for idx, item_id in enumerate(item_ids, 1):
            try:
                print(f"\n--- Processing item {idx}/{len(item_ids)}: ID {item_id} ---")

                # Get current item data
                item_url = f"{self.api_url}/items/{item_id}"
                print(f"DEBUG: GET request to: {item_url}")
                response = self.session.get(item_url)
                print(f"DEBUG: GET response status: {response.status_code}")
                response.raise_for_status()
                item_data = response.json()

                item_title = item_data.get('o:title', 'Untitled')
                print(f"DEBUG: Item title: '{item_title}'")

                # Check if item is already in the item set
                current_itemsets = item_data.get('o:item_set', [])
                itemset_ids = [s['o:id'] for s in current_itemsets]

                print(f"DEBUG: Current item sets for this item: {itemset_ids}")

                if itemset_id in itemset_ids:
                    print(f"DEBUG: Item {item_id} is already in target item set {itemset_id} - skipping")
                    already_in_set += 1
                    success_count += 1
                    continue

                # Add the new item set to the list with both @id and o:id
                # (matching the format of existing item sets)
                new_item_set = {
                    '@id': f"{self.api_url}/item_sets/{itemset_id}",
                    'o:id': itemset_id
                }
                current_itemsets.append(new_item_set)
                new_itemset_ids = [s['o:id'] for s in current_itemsets]

                print(f"DEBUG: Adding item set {itemset_id} to item {item_id}")
                print(f"DEBUG: New item set list will be: {new_itemset_ids}")
                print(f"DEBUG: New item set entry: {new_item_set}")

                # Update the item data with new item sets
                item_data['o:item_set'] = current_itemsets

                # Remove fields that shouldn't be sent in PUT request
                # These are read-only, computed, or JSON-LD context fields
                fields_to_remove = ['@context', '@id', '@type', 'o:id', 'o:owner',
                                    'o:created', 'o:modified', 'thumbnail_display_urls',
                                    'o:primary_media', 'o:media', 'o:site', '@reverse']

                update_data = {k: v for k, v in item_data.items() if k not in fields_to_remove}

                print(f"DEBUG: PUT data keys: {list(update_data.keys())}")
                print(f"DEBUG: o:item_set value: {update_data.get('o:item_set')}")
                print(f"DEBUG: PUT request to: {item_url}")
                response = self.session.put(
                    item_url,
                    json=update_data,
                    headers={'Content-Type': 'application/json'}
                )

                print(f"DEBUG: PUT response status: {response.status_code}")

                # Print response details if there's an error
                if response.status_code != 200:
                    print(f"ERROR: Response status: {response.status_code}", file=sys.stderr)
                    print(f"ERROR: Response headers: {dict(response.headers)}", file=sys.stderr)
                    print(f"ERROR: Response body: {response.text}", file=sys.stderr)

                response.raise_for_status()

                print(f"SUCCESS: Added item {item_id} ('{item_title}') to item set {itemset_id}")
                success_count += 1

            except requests.exceptions.RequestException as e:
                print(f"ERROR: Failed to add item {item_id} to item set: {e}", file=sys.stderr)
                if hasattr(e, 'response') and e.response is not None:
                    print(f"ERROR: Response status: {e.response.status_code}", file=sys.stderr)
                    print(f"ERROR: Response body: {e.response.text}", file=sys.stderr)
                error_count += 1

        print(f"\n=== FINAL SUMMARY ===")
        print(f"Total items processed: {len(item_ids)}")
        print(f"Successfully added: {success_count - already_in_set}")
        print(f"Already in set (skipped): {already_in_set}")
        print(f"Errors: {error_count}")

However, I’m getting the following response

ERROR: Response status: 403
ERROR: Response headers: {'Date': 'Tue, 27 Jan 2026 16:50:38 GMT', 'Server': 'Apache/2.4.52 (Ubuntu)', 'Omeka-S-Version': '4.1.1', 'Keep-Alive': 'timeout=5, max=52', 'Connection': 'Keep-Alive', 'Transfer-Encoding': 'chunked', 'Content-Type': 'application/ld+json'}
ERROR: Response body: {"errors":{"error":"Permission denied for the current user to update the Omeka\\Api\\Adapter\\ItemAdapter resource."}}
ERROR: Failed to add item 209995 to item set: 403 Client Error: Forbidden for url: https://urprojectsdev.lib.rochester.edu/api/items/209995
ERROR: Response status: 403
ERROR: Response body: {"errors":{"error":"Permission denied for the current user to update the Omeka\\Api\\Adapter\\ItemAdapter resource."}}

Its unclear to me what I need to do to resolve this 403, as the user’s api key has global admin access.

Here’s what I get for the item set when I put in a get request

{
	"@context": "https://urprojectsdev.lib.rochester.edu/api-context",
	"@id": "https://urprojectsdev.lib.rochester.edu/api/item_sets/213519",
	"@type": "o:ItemSet",
	"o:id": 213519,
	"o:is_public": true,
	"o:owner": {
		"@id": "https://urprojectsdev.lib.rochester.edu/api/users/1",
		"o:id": 1
	},
	"o:resource_class": null,
	"o:resource_template": null,
	"o:thumbnail": null,
	"o:title": "Artists",
	"thumbnail_display_urls": {
		"large": null,
		"medium": null,
		"square": null
	},
	"o:created": {
		"@value": "2026-01-20T16:07:02+00:00",
		"@type": "http://www.w3.org/2001/XMLSchema#dateTime"
	},
	"o:modified": {
		"@value": "2026-01-20T16:07:02+00:00",
		"@type": "http://www.w3.org/2001/XMLSchema#dateTime"
	},
	"o:is_open": true,
	"o:items": {
		"@id": "https://urprojectsdev.lib.rochester.edu/api/items?item_set_id=213519"
	},
	"dcterms:title": [
		{
			"type": "literal",
			"property_id": 1,
			"property_label": "Title",
			"is_public": true,
			"@value": "Artists"
		}
	]
}

It’s unclear to me how I resolve this. Even doing this in insomnia, I get the permission denied issue, so I don’t think its my code, unless omeka is expecting something strange for the payload

Just to deal with the most obvious first: you’re sure you’re sending the key_identity and key_credential?

I am, both in insomnia and in the script

Can you make any updates/edits at all?

Interestingly enough, while I have been able to without issue on a different server, I can’t seem to on this one (yes, I’m using the right API credentials). It’s not firewall, though, as I AM getting valid responses for my gets

Is there something up with how I’m structuring the URL? It looks the same as in my other server’s requests

https://urprojectsdev.lib.rochester.edu/api/items/213520?key_identity=[Censored]&key_credential=[Censored]

The easy answer is going to be that there’s some problem with the credentials. I don’t have a lot of other options for you as to what else would be happening here. Your URL looks fine to me.

You could try generating and using a new key just to rule that out, I guess.

I don’t think it would be a firewall, even one only reacting to the PUTs, because you’re getting our “not allowed” error message: it’s Omeka S returning that 403, not some earlier layer.

Did you try to use the native /s/ URL and not the CleanURL one?

Hey Boregar, I’m not sure what you mean by this.

Also, I just tried regenerating my credentials, no dice. Does Omeka S log 403s somewhere with more information?

Found my issue. I had a trailing space in my API credentials. That’d do it. Sorry everyone.

Just to follow up and clarify on the “/s/” thing: they’re talking about the “/s/” that’s normally at the front of the web URLs for sites. The API URLs don’t have that part, so nothing for you to worry about there.

Happy to hear you tracked down your issue.